Skip to content

Commit 1582549

Browse files
committed
feat(config): expand {env:VAR} and {file:path} tokens in magic-context.jsonc
Users of OpenCode's main config can already reference environment variables as `{env:OPENAI_API_KEY}` and inline external files with `{file:./key.txt}`. Magic Context didn't implement the same substitution, so writing `"api_key": "{env:OPENAI_API_KEY}"` in magic-context.jsonc sent the literal string to providers and caused silent auth failures — the most visible symptom was reported in issue #29 with OpenRouter embeddings. This change adds the same substitution pass, mirroring OpenCode's `ConfigVariable.substitute` in `packages/opencode/src/config/variable.ts` so the patterns are consistent across both config files: - `{env:VAR}` — trimmed key looked up in `process.env`; missing values become empty strings and produce a warning (OpenCode throws by default; we soften to a warning because magic-context config is less critical). - `{file:~/abs}`, `{file:./rel}`, `{file:rel}`, `{file:/abs}` — contents read, trimmed, and JSON-escaped so embedded quotes/newlines survive the outer JSONC parse. Paths resolve against the config file's directory; tokens inside `//` line comments pass through verbatim. Substitution runs on raw text before JSONC parsing, matching OpenCode's ordering. `loadConfigFile` now returns both the parsed config and any substitution warnings, prefixed with the config path; warnings are bubbled up through `loadPluginConfig`'s existing `configWarnings` channel so the plugin's startup notifier already surfaces them without extra plumbing. Docs: CONFIGURATION.md now shows `{env:OPENAI_API_KEY}` in the embedding example and explains the substitution behavior. Tests: 18 unit tests in `variable.test.ts` covering env present/missing/ trimmed/empty-string, file absolute/relative/home/missing, JSON escaping of multiline contents, comment passthrough, combined env+file, nested env-inside-file tokens, and no-token no-op cases. All 33 config tests pass; full suite remains green.
1 parent b774208 commit 1582549

3 files changed

Lines changed: 395 additions & 8 deletions

File tree

packages/plugin/src/config/index.ts

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { join } from "node:path";
44

55
import { detectConfigFile, parseJsonc } from "../shared/jsonc-parser";
66
import { type MagicContextConfig, MagicContextConfigSchema } from "./schema/magic-context";
7+
import { substituteConfigVariables } from "./variable";
78

89
export interface MagicContextPluginConfig extends MagicContextConfig {
910
disabled_hooks?: string[];
@@ -30,12 +31,27 @@ function getProjectConfigBasePath(directory: string): string {
3031
return join(directory, ".opencode", CONFIG_FILE_BASENAME);
3132
}
3233

33-
function loadConfigFile(configPath: string): Record<string, unknown> | null {
34+
interface LoadedConfigFile {
35+
config: Record<string, unknown>;
36+
/** Warnings from {env:} / {file:} substitution, with config-path prefix applied. */
37+
warnings: string[];
38+
}
39+
40+
function loadConfigFile(configPath: string): LoadedConfigFile | null {
3441
try {
3542
if (!existsSync(configPath)) {
3643
return null;
3744
}
38-
return parseJsonc<Record<string, unknown>>(readFileSync(configPath, "utf-8"));
45+
const rawText = readFileSync(configPath, "utf-8");
46+
// Substitute {env:VAR} and {file:path} tokens on the raw text before
47+
// parsing so users can reference env vars (API keys) and external files
48+
// without leaking secrets into the config file itself. Matches OpenCode's
49+
// ConfigVariable.substitute semantics exactly.
50+
const substituted = substituteConfigVariables({ text: rawText, configPath });
51+
return {
52+
config: parseJsonc<Record<string, unknown>>(substituted.text),
53+
warnings: substituted.warnings.map((w) => `${configPath}: ${w}`),
54+
};
3955
} catch (error) {
4056
console.warn(
4157
`[magic-context] failed to load config from ${configPath}:`,
@@ -163,23 +179,27 @@ export function loadPluginConfig(
163179
const dotOpenCodeDetected = detectConfigFile(getProjectConfigBasePath(directory));
164180
const projectDetected = rootDetected.format !== "none" ? rootDetected : dotOpenCodeDetected;
165181

166-
const userConfig = userDetected.format === "none" ? null : loadConfigFile(userDetected.path);
167-
const projectConfig =
182+
const userLoaded = userDetected.format === "none" ? null : loadConfigFile(userDetected.path);
183+
const projectLoaded =
168184
projectDetected.format === "none" ? null : loadConfigFile(projectDetected.path);
169185

170186
let config: MagicContextPluginConfig & { configWarnings?: string[] } = parsePluginConfig({});
171187
const allWarnings: string[] = [];
172188

173-
if (userConfig) {
174-
const parsed = parsePluginConfig(userConfig);
189+
if (userLoaded) {
190+
// Variable-substitution warnings surface first so users see missing
191+
// env vars before any downstream schema-validation warnings.
192+
allWarnings.push(...userLoaded.warnings.map((w) => `[user config] ${w}`));
193+
const parsed = parsePluginConfig(userLoaded.config);
175194
if (parsed.configWarnings?.length) {
176195
allWarnings.push(...parsed.configWarnings.map((w) => `[user config] ${w}`));
177196
}
178197
config = mergeConfigs(config, parsed);
179198
}
180199

181-
if (projectConfig) {
182-
const parsed = parsePluginConfig(projectConfig);
200+
if (projectLoaded) {
201+
allWarnings.push(...projectLoaded.warnings.map((w) => `[project config] ${w}`));
202+
const parsed = parsePluginConfig(projectLoaded.config);
183203
if (parsed.configWarnings?.length) {
184204
allWarnings.push(...parsed.configWarnings.map((w) => `[project config] ${w}`));
185205
}
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import { homedir, tmpdir } from "node:os";
4+
import { join, sep } from "node:path";
5+
6+
import { substituteConfigVariables } from "./variable";
7+
8+
describe("substituteConfigVariables", () => {
9+
const ORIGINAL_ENV = { ...process.env };
10+
let tmpDir: string;
11+
12+
beforeEach(() => {
13+
tmpDir = mkdtempSync(join(tmpdir(), "mc-variable-test-"));
14+
});
15+
16+
afterEach(() => {
17+
rmSync(tmpDir, { recursive: true, force: true });
18+
process.env = { ...ORIGINAL_ENV };
19+
});
20+
21+
describe("env substitution", () => {
22+
it("replaces {env:VAR} with process.env value", () => {
23+
process.env.MC_TEST_KEY = "sk-real-value";
24+
const input = `{ "api_key": "{env:MC_TEST_KEY}" }`;
25+
26+
const result = substituteConfigVariables({ text: input });
27+
28+
expect(result.text).toBe(`{ "api_key": "sk-real-value" }`);
29+
expect(result.warnings).toHaveLength(0);
30+
});
31+
32+
it("trims whitespace inside env token", () => {
33+
process.env.MC_TEST_KEY = "trimmed-value";
34+
const input = `{ "api_key": "{env: MC_TEST_KEY }" }`;
35+
36+
const result = substituteConfigVariables({ text: input });
37+
38+
expect(result.text).toBe(`{ "api_key": "trimmed-value" }`);
39+
expect(result.warnings).toHaveLength(0);
40+
});
41+
42+
it("emits warning and empty string for missing env var", () => {
43+
delete process.env.MC_MISSING_VAR;
44+
const input = `{ "api_key": "{env:MC_MISSING_VAR}" }`;
45+
46+
const result = substituteConfigVariables({ text: input });
47+
48+
expect(result.text).toBe(`{ "api_key": "" }`);
49+
expect(result.warnings).toHaveLength(1);
50+
expect(result.warnings[0]).toContain("MC_MISSING_VAR");
51+
expect(result.warnings[0]).toContain("not set");
52+
});
53+
54+
it("emits warning for empty-string env var", () => {
55+
process.env.MC_EMPTY = "";
56+
const input = `{ "api_key": "{env:MC_EMPTY}" }`;
57+
58+
const result = substituteConfigVariables({ text: input });
59+
60+
expect(result.text).toBe(`{ "api_key": "" }`);
61+
expect(result.warnings).toHaveLength(1);
62+
});
63+
64+
it("passes {env:} literally through (matches OpenCode regex: at least one char required)", () => {
65+
const input = `{ "api_key": "{env:}" }`;
66+
67+
const result = substituteConfigVariables({ text: input });
68+
69+
// The regex `{env:([^}]+)}` requires ≥1 char between the colon and
70+
// brace. An empty `{env:}` is not a valid token and passes through
71+
// to be parsed as literal JSONC text.
72+
expect(result.text).toBe(input);
73+
expect(result.warnings).toHaveLength(0);
74+
});
75+
76+
it("handles multiple env tokens in one text", () => {
77+
process.env.MC_A = "alpha";
78+
process.env.MC_B = "beta";
79+
const input = `{ "a": "{env:MC_A}", "b": "{env:MC_B}", "c": "{env:MC_MISSING}" }`;
80+
81+
const result = substituteConfigVariables({ text: input });
82+
83+
expect(result.text).toBe(`{ "a": "alpha", "b": "beta", "c": "" }`);
84+
expect(result.warnings).toHaveLength(1);
85+
expect(result.warnings[0]).toContain("MC_MISSING");
86+
});
87+
});
88+
89+
describe("file substitution", () => {
90+
it("inlines file contents for absolute path", () => {
91+
const keyFile = join(tmpDir, "key.txt");
92+
writeFileSync(keyFile, "sk-from-file\n");
93+
const input = `{ "api_key": "{file:${keyFile}}" }`;
94+
95+
const result = substituteConfigVariables({ text: input });
96+
97+
expect(result.text).toBe(`{ "api_key": "sk-from-file" }`);
98+
expect(result.warnings).toHaveLength(0);
99+
});
100+
101+
it("resolves relative path against configPath directory", () => {
102+
const keyFile = join(tmpDir, "key.txt");
103+
writeFileSync(keyFile, "relative-value");
104+
const configPath = join(tmpDir, "magic-context.jsonc");
105+
106+
const input = `{ "api_key": "{file:./key.txt}" }`;
107+
const result = substituteConfigVariables({ text: input, configPath });
108+
109+
expect(result.text).toBe(`{ "api_key": "relative-value" }`);
110+
expect(result.warnings).toHaveLength(0);
111+
});
112+
113+
it("resolves relative path without leading ./", () => {
114+
const keyFile = join(tmpDir, "key.txt");
115+
writeFileSync(keyFile, "no-dot-slash");
116+
const configPath = join(tmpDir, "magic-context.jsonc");
117+
118+
const input = `{ "api_key": "{file:key.txt}" }`;
119+
const result = substituteConfigVariables({ text: input, configPath });
120+
121+
expect(result.text).toBe(`{ "api_key": "no-dot-slash" }`);
122+
});
123+
124+
it("expands ~/ to home directory", () => {
125+
const input = `{ "marker": "{file:~/__mc-never-exists-${Date.now()}}" }`;
126+
127+
const result = substituteConfigVariables({ text: input });
128+
129+
// File doesn't exist, so warning fires — but warning must show it
130+
// resolved under homedir, proving the ~ expansion happened.
131+
expect(result.warnings).toHaveLength(1);
132+
expect(result.warnings[0]).toContain(homedir() + sep);
133+
});
134+
135+
it("JSON-escapes quotes and newlines in file contents", () => {
136+
const keyFile = join(tmpDir, "multiline.txt");
137+
writeFileSync(keyFile, 'line1 with "quote"\nline2');
138+
const input = `{ "value": "{file:${keyFile}}" }`;
139+
140+
const result = substituteConfigVariables({ text: input });
141+
142+
// Escaped so the outer JSONC string still parses cleanly.
143+
expect(result.text).toBe(`{ "value": "line1 with \\"quote\\"\\nline2" }`);
144+
const parsed = JSON.parse(result.text);
145+
expect(parsed.value).toBe('line1 with "quote"\nline2');
146+
});
147+
148+
it("emits warning and empty string for missing file", () => {
149+
const missing = join(tmpDir, "never-exists.txt");
150+
const input = `{ "api_key": "{file:${missing}}" }`;
151+
152+
const result = substituteConfigVariables({ text: input });
153+
154+
expect(result.text).toBe(`{ "api_key": "" }`);
155+
expect(result.warnings).toHaveLength(1);
156+
expect(result.warnings[0]).toContain("not found");
157+
expect(result.warnings[0]).toContain(missing);
158+
});
159+
160+
it("passes {file:} literally through (matches OpenCode regex: at least one char required)", () => {
161+
const input = `{ "api_key": "{file:}" }`;
162+
163+
const result = substituteConfigVariables({ text: input });
164+
165+
// Same reasoning as {env:} — empty token pattern doesn't match.
166+
expect(result.text).toBe(input);
167+
expect(result.warnings).toHaveLength(0);
168+
});
169+
170+
it("preserves {file:} tokens inside // line comments", () => {
171+
const keyFile = join(tmpDir, "key.txt");
172+
writeFileSync(keyFile, "should-not-appear");
173+
const input = [
174+
`{`,
175+
` // see docs: {file:${keyFile}}`,
176+
` "other": "value"`,
177+
`}`,
178+
].join("\n");
179+
180+
const result = substituteConfigVariables({ text: input });
181+
182+
// Token inside comment stays literal — only active values substitute.
183+
expect(result.text).toContain(`// see docs: {file:${keyFile}}`);
184+
expect(result.text).not.toContain("should-not-appear");
185+
expect(result.warnings).toHaveLength(0);
186+
});
187+
});
188+
189+
describe("combined substitution", () => {
190+
it("handles env and file tokens together", () => {
191+
process.env.MC_COMBINED = "env-val";
192+
const keyFile = join(tmpDir, "combined.txt");
193+
writeFileSync(keyFile, "file-val");
194+
195+
const input = `{ "e": "{env:MC_COMBINED}", "f": "{file:${keyFile}}" }`;
196+
const result = substituteConfigVariables({ text: input });
197+
198+
expect(result.text).toBe(`{ "e": "env-val", "f": "file-val" }`);
199+
expect(result.warnings).toHaveLength(0);
200+
});
201+
202+
it("env tokens inside {file:} path expand before file read", () => {
203+
// Tokens are substituted left-to-right in passes: env first, then
204+
// file. So an env var naming a file path gets resolved during file
205+
// substitution.
206+
process.env.MC_FILE_DIR = tmpDir;
207+
const keyFile = join(tmpDir, "indirect.txt");
208+
writeFileSync(keyFile, "indirect-value");
209+
210+
const input = `{ "api_key": "{file:{env:MC_FILE_DIR}/indirect.txt}" }`;
211+
const result = substituteConfigVariables({ text: input });
212+
213+
expect(result.text).toBe(`{ "api_key": "indirect-value" }`);
214+
});
215+
});
216+
217+
describe("no-op cases", () => {
218+
it("returns text unchanged when no tokens present", () => {
219+
const input = `{ "api_key": "literal-value", "provider": "openai-compatible" }`;
220+
221+
const result = substituteConfigVariables({ text: input });
222+
223+
expect(result.text).toBe(input);
224+
expect(result.warnings).toHaveLength(0);
225+
});
226+
227+
it("leaves partial patterns like {env alone", () => {
228+
const input = `{ "note": "this {env is not a token" }`;
229+
230+
const result = substituteConfigVariables({ text: input });
231+
232+
expect(result.text).toBe(input);
233+
expect(result.warnings).toHaveLength(0);
234+
});
235+
});
236+
});

0 commit comments

Comments
 (0)