feat(opencode): add JSONC config support without external dependency (#205)

OpenCode supports opencode.jsonc as config format. Added .jsonc paths
to config detection and a 3-line stripJsonComments function instead of
the jsonc-parser dependency (208KB). Priority: .json before .jsonc at
each level. writeSettings writes back to the same file it read.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mert Koseoglu
2026-04-02 02:03:26 +03:00
co-authored by Claude Opus 4.6
parent c2db524509
commit 7189ed6c0f
2 changed files with 122 additions and 3 deletions
+15 -3
View File
@@ -17,6 +17,14 @@
*/
import { createHash } from "node:crypto";
/** Strip JSONC comments (// and /* *​/) and trailing commas for JSON.parse. */
function stripJsonComments(str: string): string {
return str
.replace(/\/\/.*$/gm, "")
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/,(\s*[}\]])/g, "$1");
}
import {
readFileSync,
writeFileSync,
@@ -230,8 +238,11 @@ export class OpenCodeAdapter implements HookAdapter {
}
return [
resolve("opencode.json"),
resolve("opencode.jsonc"),
resolve(".opencode", "opencode.json"),
resolve(".opencode", "opencode.jsonc"),
join(homedir(), ".config", "opencode", "opencode.json"),
join(homedir(), ".config", "opencode", "opencode.jsonc"),
];
}
@@ -307,7 +318,8 @@ export class OpenCodeAdapter implements HookAdapter {
try {
const raw = readFileSync(configPath, "utf-8");
this.settingsPath = configPath;
return JSON.parse(raw) as Record<string, unknown>;
const text = configPath.endsWith(".jsonc") ? stripJsonComments(raw) : raw;
return JSON.parse(text) as Record<string, unknown>;
} catch {
continue;
}
@@ -334,7 +346,7 @@ export class OpenCodeAdapter implements HookAdapter {
results.push({
check: "Plugin configuration",
status: "fail",
message: "Could not read opencode.json",
message: `Could not read ${this.platform}.json or ${this.platform}.jsonc`,
fix: "context-mode upgrade",
});
return results;
@@ -380,7 +392,7 @@ export class OpenCodeAdapter implements HookAdapter {
return {
check: "Plugin registration",
status: "warn",
message: "Could not read opencode.json",
message: `Could not read ${this.platform}.json or ${this.platform}.jsonc`,
};
}
+107
View File
@@ -257,6 +257,113 @@ describe("OpenCodeAdapter", () => {
rmSync(root, { recursive: true, force: true });
});
it("readSettings reads opencode.jsonc with comments stripped", () => {
const root = mkdtempSync(join(tmpdir(), "opencode-adapter-"));
const dir = join(root, "project");
const src = resolve(process.cwd(), "src", "adapters", "opencode", "index.ts");
const tsx = resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.mjs");
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, "opencode.jsonc"),
`{
// This is a line comment
"plugin": ["context-mode"],
/* Block comment */
"version": "1.0"
}
`,
);
const run = spawnSync(
process.execPath,
[
tsx,
"-e",
`import { OpenCodeAdapter } from ${JSON.stringify(src)};const a=new OpenCodeAdapter();console.log(JSON.stringify(a.readSettings()))`,
],
{ cwd: dir, env: env(join(root, "home")), encoding: "utf-8" },
);
expect(run.status).toBe(0);
expect(JSON.parse(run.stdout)).toEqual({ plugin: ["context-mode"], version: "1.0" });
rmSync(root, { recursive: true, force: true });
});
it("prefers opencode.json over opencode.jsonc when both exist", () => {
const root = mkdtempSync(join(tmpdir(), "opencode-adapter-"));
const dir = join(root, "project");
const src = resolve(process.cwd(), "src", "adapters", "opencode", "index.ts");
const tsx = resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.mjs");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "opencode.json"), JSON.stringify({ from: "json" }));
writeFileSync(join(dir, "opencode.jsonc"), `{ /* comment */ "from": "jsonc" }`);
const run = spawnSync(
process.execPath,
[
tsx,
"-e",
`import { OpenCodeAdapter } from ${JSON.stringify(src)};const a=new OpenCodeAdapter();console.log(JSON.stringify(a.readSettings()))`,
],
{ cwd: dir, env: env(join(root, "home")), encoding: "utf-8" },
);
expect(run.status).toBe(0);
expect(JSON.parse(run.stdout)).toEqual({ from: "json" });
rmSync(root, { recursive: true, force: true });
});
it("configureAllHooks works with opencode.jsonc", () => {
const root = mkdtempSync(join(tmpdir(), "opencode-adapter-"));
const dir = join(root, "project");
const src = resolve(process.cwd(), "src", "adapters", "opencode", "index.ts");
const tsx = resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.mjs");
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, "opencode.jsonc"),
`{
// My config
"plugin": []
}
`,
);
const run = spawnSync(
process.execPath,
[
tsx,
"-e",
`import { OpenCodeAdapter } from ${JSON.stringify(src)};const a=new OpenCodeAdapter();console.log(JSON.stringify(a.configureAllHooks('/tmp/plugin')))`,
],
{ cwd: dir, env: env(join(root, "home")), encoding: "utf-8" },
);
expect(run.status).toBe(0);
expect(JSON.parse(run.stdout)).toEqual(["Added context-mode to plugin array"]);
// Should write back to .jsonc (same file it read)
expect(JSON.parse(readFileSync(join(dir, "opencode.jsonc"), "utf-8"))).toEqual({
plugin: ["context-mode"],
});
rmSync(root, { recursive: true, force: true });
});
it("validates hooks with jsonc config shows correct error message", () => {
const root = mkdtempSync(join(tmpdir(), "opencode-adapter-"));
const dir = join(root, "project");
const src = resolve(process.cwd(), "src", "adapters", "opencode", "index.ts");
const tsx = resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.mjs");
mkdirSync(dir, { recursive: true });
// No config file at all
const run = spawnSync(
process.execPath,
[
tsx,
"-e",
`import { OpenCodeAdapter } from ${JSON.stringify(src)};const a=new OpenCodeAdapter();console.log(JSON.stringify(a.validateHooks('/tmp')))`,
],
{ cwd: dir, env: env(join(root, "home")), encoding: "utf-8" },
);
expect(run.status).toBe(0);
const results = JSON.parse(run.stdout);
const pluginCheck = results.find((r: { check: string }) => r.check === "Plugin configuration");
expect(pluginCheck.message).toContain("jsonc");
rmSync(root, { recursive: true, force: true });
});
it("configureAllHooks writes back to .opencode/opencode.json when that is the selected config", () => {
const root = mkdtempSync(join(tmpdir(), "opencode-adapter-"));
const dir = join(root, "project");