Skip to content

Commit cd2e540

Browse files
authored
fix(coding-agents): stop opencode's install from replacing a JSONC config (#3843)
opencode loads its global config from either `~/.config/opencode/opencode.json` or `opencode.jsonc`. The installer knew only the first name and read it with `readJson`, whose strict `JSON.parse` rejects the comments and trailing commas a hand-maintained config carries and whose `catch` returns `{}`. A user whose config is `opencode.jsonc` got a second config file created beside their real one; had it been named `.json`, the `{}` fallback would have been written back carrying only our `plugin` key, taking their providers, MCP servers and agent overrides with it. Three layers change: - `opencodeConfigPath` probes `opencode.jsonc` then `opencode.json`, edits whichever exists, and creates `opencode.json` only when neither does — the same shape as `kiloConfigPath`. - The adapter parses with `parseJsonc` and aborts with a `SKIPPED` log on a config it cannot read, rather than falling back to `{}`. - `writeJsonc` sets or deletes one top-level key through `jsonc-parser`, so text it did not touch survives byte for byte, and a CRLF file stays CRLF. Parsing was only half the problem: a config that read correctly still came back stripped, because `writeJson` round-trips through `JSON.stringify`. kilo had the JSONC-aware read and the same lossy write, and its test asserted the loss; it now writes through `writeJsonc` too, with a parity guard sweeping both JSONC hosts through a real install and uninstall. `jsonc-parser` goes in tsup's `noExternal`: `installer.js` is staged to `~/.hindsight/coding-agents` as dist + skill + package.json and never `node_modules`, so an external import there cannot resolve and re-running `install` from the staged copy would exit with `ERR_MODULE_NOT_FOUND`. Comments inside the `plugin` array are still lost, since that value is re-emitted; everything outside it is kept.
1 parent 72c9c8f commit cd2e540

5 files changed

Lines changed: 262 additions & 24 deletions

File tree

hindsight-integrations/coding-agents/package-lock.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

hindsight-integrations/coding-agents/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
"dependencies": {
7676
"@modelcontextprotocol/sdk": "^1.29.0",
7777
"@vectorize-io/hindsight-all": "^0.8.6",
78+
"jsonc-parser": "^3.3.1",
7879
"zod": "^4.4.3"
7980
},
8081
"devDependencies": {

hindsight-integrations/coding-agents/src/installer.test.ts

Lines changed: 161 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
import { tmpdir } from "node:os";
1212
import { dirname, join } from "node:path";
1313
import { pathToFileURL } from "node:url";
14-
import { INSTALLERS, MARKER, run, type InstallCtx } from "./installer";
14+
import { INSTALLERS, MARKER, parseJsonc, run, type InstallCtx } from "./installer";
1515

1616
// Every test gets a FRESH temp dir as ctx.home (never the real $HOME) and a stubbed
1717
// claudeMcp so the real `claude` CLI is never executed. run() is always called with
@@ -463,9 +463,13 @@ describe("kilo installer", () => {
463463
writeFileSync(jsonc, '{\n // my config\n "$schema": "https://app.kilo.ai/config.json"\n}\n');
464464
run(["install", "kilo"], ctx);
465465
expect(existsSync(join(kiloDir(ctx), "kilo.json"))).toBe(false);
466-
const cfg = readJson(jsonc);
466+
const text = readFileSync(jsonc, "utf8");
467+
const cfg = parseJsonc(text)!;
467468
expect(cfg.plugin).toEqual([entryOf(ctx)]);
468-
expect(cfg.$schema).toBe("https://app.kilo.ai/config.json"); // comments dropped, DATA kept
469+
expect(cfg.$schema).toBe("https://app.kilo.ai/config.json");
470+
// The comment used to be dropped here: the read was JSONC-aware but the write re-serialized
471+
// the parsed object, so a commented config came back stripped.
472+
expect(text).toContain("// my config");
469473
});
470474

471475
it("refuses to clobber a config it cannot parse", () => {
@@ -488,7 +492,124 @@ describe("kilo installer", () => {
488492
});
489493

490494
describe("opencode installer", () => {
491-
const cfgPath = (ctx: InstallCtx) => join(ctx.home, ".config", "opencode", "opencode.json");
495+
const ocDir = (ctx: InstallCtx) => join(ctx.home, ".config", "opencode");
496+
const cfgPath = (ctx: InstallCtx) => join(ocDir(ctx), "opencode.json");
497+
498+
// opencode loads `opencode.json` OR `opencode.jsonc` from ~/.config/opencode. Creating the
499+
// .json variant next to an existing .jsonc leaves the user with two configs — their settings in
500+
// one, our plugin entry in the other — so the install has to edit whichever already exists.
501+
it("edits an existing opencode.jsonc instead of creating a competing opencode.json", () => {
502+
const ctx = makeCtx();
503+
const jsonc = join(ocDir(ctx), "opencode.jsonc");
504+
mkdirSync(ocDir(ctx), { recursive: true });
505+
writeFileSync(jsonc, '{\n "$schema": "https://opencode.ai/config.json"\n}\n');
506+
run(["install", "opencode"], ctx);
507+
expect(existsSync(cfgPath(ctx))).toBe(false);
508+
expect(readJson(jsonc).plugin).toEqual([ctx.pkgRoot]);
509+
});
510+
511+
it("uninstall edits the same opencode.jsonc the install wrote to", () => {
512+
const ctx = makeCtx();
513+
const jsonc = join(ocDir(ctx), "opencode.jsonc");
514+
mkdirSync(ocDir(ctx), { recursive: true });
515+
writeFileSync(jsonc, "{}\n");
516+
run(["install", "opencode"], ctx);
517+
run(["uninstall", "opencode"], ctx);
518+
expect(readJson(jsonc).plugin).toBeUndefined();
519+
expect(existsSync(cfgPath(ctx))).toBe(false);
520+
});
521+
522+
it("creates opencode.json when neither candidate exists", () => {
523+
const ctx = makeCtx();
524+
run(["install", "opencode"], ctx);
525+
expect(existsSync(cfgPath(ctx))).toBe(true);
526+
expect(existsSync(join(ocDir(ctx), "opencode.jsonc"))).toBe(false);
527+
});
528+
529+
// The reported data loss: a commented config went through strict JSON.parse, which threw, and
530+
// readJson's {} fallback meant the whole file was rewritten as just our plugin key. Every
531+
// provider, agent override and MCP entry in it was gone.
532+
it("keeps the rest of a commented config instead of replacing it with just our key", () => {
533+
const ctx = makeCtx();
534+
const jsonc = join(ocDir(ctx), "opencode.jsonc");
535+
mkdirSync(ocDir(ctx), { recursive: true });
536+
writeFileSync(
537+
jsonc,
538+
`{\n // where memory lives\n "share": "disabled",\n "provider": {\n "openai": { "name": "gw" },\n },\n}\n`
539+
);
540+
run(["install", "opencode"], ctx);
541+
const cfg = parseJsonc(readFileSync(jsonc, "utf8"))!;
542+
expect(cfg.plugin).toEqual([ctx.pkgRoot]);
543+
expect(cfg.share).toBe("disabled"); // survived — this is what used to be wiped
544+
expect(cfg.provider).toEqual({ openai: { name: "gw" } });
545+
});
546+
547+
// Reading the file correctly is only half of it: re-serializing the parsed object would drop
548+
// every comment the user wrote, so a config that survived would still come back damaged.
549+
it("leaves the user's comments and formatting in place", () => {
550+
const ctx = makeCtx();
551+
const jsonc = join(ocDir(ctx), "opencode.jsonc");
552+
mkdirSync(ocDir(ctx), { recursive: true });
553+
writeFileSync(
554+
jsonc,
555+
`{\n /** Providers **/\n "provider": {\n // via the gateway\n "openai": { "name": "gw" },\n },\n}\n`
556+
);
557+
run(["install", "opencode"], ctx);
558+
const text = readFileSync(jsonc, "utf8");
559+
expect(text).toContain("/** Providers **/");
560+
expect(text).toContain("// via the gateway");
561+
expect(text).toContain('"openai": { "name": "gw" },');
562+
});
563+
564+
it("uninstall drops the plugin key without reformatting the file", () => {
565+
const ctx = makeCtx();
566+
const jsonc = join(ocDir(ctx), "opencode.jsonc");
567+
mkdirSync(ocDir(ctx), { recursive: true });
568+
writeFileSync(jsonc, `{\n /** mine **/\n "share": "disabled",\n}\n`);
569+
run(["install", "opencode"], ctx);
570+
run(["uninstall", "opencode"], ctx);
571+
const text = readFileSync(jsonc, "utf8");
572+
expect(text).toContain("/** mine **/");
573+
expect(parseJsonc(text)).toEqual({ share: "disabled" });
574+
});
575+
576+
// Trailing commas are as common as comments in a hand-written config, and JSON.parse rejects
577+
// both — so a .json file carrying them hit the very same wipe.
578+
it("parses a trailing-comma opencode.json rather than clobbering it", () => {
579+
const ctx = makeCtx();
580+
mkdirSync(ocDir(ctx), { recursive: true });
581+
writeFileSync(
582+
cfgPath(ctx),
583+
`{\n "model": "openai/gpt-5",\n "plugin": [\n "other",\n ],\n}\n`
584+
);
585+
run(["install", "opencode"], ctx);
586+
// Read back with the JSONC parser: the trailing commas are PRESERVED by the write, so the
587+
// result is still not strict JSON — which is the point.
588+
const cfg = parseJsonc(readFileSync(cfgPath(ctx), "utf8"))!;
589+
expect(cfg.model).toBe("openai/gpt-5");
590+
expect(cfg.plugin).toEqual(["other", ctx.pkgRoot]);
591+
});
592+
593+
it("refuses to clobber a config it cannot parse", () => {
594+
const ctx = makeCtx();
595+
mkdirSync(ocDir(ctx), { recursive: true });
596+
const broken = '{ "provider": { unquoted } }';
597+
writeFileSync(cfgPath(ctx), broken);
598+
const logs: string[] = [];
599+
ctx.log = (m) => logs.push(m);
600+
run(["install", "opencode"], ctx);
601+
expect(readFileSync(cfgPath(ctx), "utf8")).toBe(broken);
602+
expect(logs.join("\n")).toContain("SKIPPED");
603+
});
604+
605+
it("uninstall leaves an unparseable config untouched", () => {
606+
const ctx = makeCtx();
607+
mkdirSync(ocDir(ctx), { recursive: true });
608+
const broken = '{ "provider": { unquoted } }';
609+
writeFileSync(cfgPath(ctx), broken);
610+
run(["uninstall", "opencode"], ctx);
611+
expect(readFileSync(cfgPath(ctx), "utf8")).toBe(broken);
612+
});
492613

493614
it("install adds ctx.pkgRoot to the plugin array exactly once, even across reinstalls", () => {
494615
const ctx = makeCtx();
@@ -758,6 +879,42 @@ describe("MCP registrations name the calling harness", () => {
758879
});
759880
});
760881

882+
/**
883+
* A JSONC-configured host must survive the install with its comments intact.
884+
*
885+
* Swept over the family rather than asserted per harness: opencode and kilo each read with
886+
* `parseJsonc` and then wrote with `writeJson`, which re-serializes and strips exactly what the
887+
* JSONC-aware read preserved. The sibling that forgets is by construction the one nobody wrote a
888+
* test for, so this drives the real install and checks the file that came out.
889+
*/
890+
describe("JSONC hosts keep their comments through an install", () => {
891+
// Each entry is the config file the harness edits when it already exists, with the comment we
892+
// expect to still be there afterwards. Hosts absent from this list are strict-JSON by design
893+
// (claude-code's settings.json, cursor's hooks.json, …) or not JSON at all (grok/dsh: TOML/YAML).
894+
const JSONC_HOSTS: { harness: string; relPath: string[] }[] = [
895+
{ harness: "opencode", relPath: [".config", "opencode", "opencode.jsonc"] },
896+
{ harness: "kilo", relPath: [".config", "kilo", "kilo.jsonc"] },
897+
];
898+
899+
it.each(JSONC_HOSTS)("$harness", ({ harness, relPath }) => {
900+
const ctx = makeCtx();
901+
const path = join(ctx.home, ...relPath);
902+
mkdirSync(dirname(path), { recursive: true });
903+
writeFileSync(path, `{\n // keep me\n "share": "disabled",\n}\n`);
904+
905+
expect(run(["install", harness], ctx)).toBe(0);
906+
const afterInstall = readFileSync(path, "utf8");
907+
expect(afterInstall).toContain("// keep me");
908+
expect(parseJsonc(afterInstall)!.share).toBe("disabled");
909+
expect(parseJsonc(afterInstall)!.plugin).toHaveLength(1);
910+
911+
expect(run(["uninstall", harness], ctx)).toBe(0);
912+
const afterUninstall = readFileSync(path, "utf8");
913+
expect(afterUninstall).toContain("// keep me");
914+
expect(parseJsonc(afterUninstall)).toEqual({ share: "disabled" });
915+
});
916+
});
917+
761918
/**
762919
* `all` is an explicit target rather than the default for a bare command: wiring every detected
763920
* agent rewrites a lot of a machine's config and should not happen by accident.

hindsight-integrations/coding-agents/src/installer.ts

Lines changed: 84 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { homedir, tmpdir } from "node:os";
3838
import { isatty } from "node:tty";
3939
import { dirname, join } from "node:path";
4040
import { fileURLToPath, pathToFileURL } from "node:url";
41+
import { applyEdits, modify } from "jsonc-parser";
4142
import { HOOK_HARNESSES, type HookHarnessName } from "./harness/hook-lifecycle";
4243
import { importLocalHistory } from "./core/history";
4344
import { detectLlm, hasRustToolchain, hasUvx, type LlmChoice } from "./core/daemon";
@@ -96,7 +97,10 @@ function readJson(path: string): Record<string, any> {
9697
}
9798

9899
/**
99-
* Parse a JSONC file (JSON with comments), as Kilo's `kilo.jsonc` may be.
100+
* Parse a JSONC file (JSON with comments), as Kilo's `kilo.jsonc` and opencode's `opencode.jsonc`
101+
* may be. Applied to `opencode.json` too: comments and trailing commas turn up in the wild under
102+
* that name as well, and the host reads it either way — so what decides the parser here is the
103+
* content the installer might meet, not the extension.
100104
*
101105
* Returns null — NOT {} — when the file exists but can't be parsed. readJson's {} fallback is safe
102106
* for a strict-JSON host (an unparseable file is a broken file), but here a config we merely failed
@@ -118,10 +122,46 @@ export function parseJsonc(text: string): Record<string, any> | null {
118122

119123
function writeJson(path: string, value: unknown): void {
120124
mkdirSync(dirname(path), { recursive: true });
125+
backupOnce(path);
126+
writeFileSync(path, JSON.stringify(value, null, 2) + "\n");
127+
}
128+
129+
/** The first time we touch an existing file, keep a copy of what the user had. */
130+
function backupOnce(path: string): void {
121131
if (existsSync(path) && !existsSync(`${path}.hindsight-backup`)) {
122132
copyFileSync(path, `${path}.hindsight-backup`);
123133
}
124-
writeFileSync(path, JSON.stringify(value, null, 2) + "\n");
134+
}
135+
136+
/**
137+
* Set (or, with `undefined`, delete) ONE top-level key, leaving the rest of the file's text alone.
138+
*
139+
* `writeJson` round-trips through `JSON.stringify`, which can only emit strict JSON — so writing a
140+
* JSONC host's config with it silently strips every comment and trailing comma the user wrote, and
141+
* reflows their formatting. Parsing the file was only half the problem: even a config we read
142+
* correctly came back stripped. `jsonc-parser` computes a minimal text edit instead, so everything
143+
* we did not touch survives byte for byte.
144+
*
145+
* Limited to a single key on purpose. A whole-object write is what forces a re-serialize, and both
146+
* callers only ever mutate `plugin`; a general "merge this object" helper could not preserve
147+
* anything. Comments INSIDE the edited value are still lost — the array is re-emitted — but the
148+
* rest of the document, which is all a user notices, is not.
149+
*/
150+
function writeJsonc(path: string, key: string, value: unknown): void {
151+
const text = existsSync(path) ? readFileSync(path, "utf8") : "";
152+
const edits = modify(text, [key], value, {
153+
formattingOptions: {
154+
tabSize: 2,
155+
insertSpaces: true,
156+
// Keep a CRLF file CRLF: rewriting every line ending would turn a one-line change into a
157+
// whole-file diff for anyone on Windows.
158+
eol: text.includes("\r\n") ? "\r\n" : "\n",
159+
},
160+
});
161+
const next = applyEdits(text, edits);
162+
mkdirSync(dirname(path), { recursive: true });
163+
backupOnce(path);
164+
writeFileSync(path, next.endsWith("\n") ? next : `${next}\n`);
125165
}
126166

127167
/** Hook-array merge for claude/codex-style files: drop our old entries, append the new one. */
@@ -243,26 +283,51 @@ function runClinePlugin(args: string[]): boolean {
243283
}
244284
}
245285

286+
/**
287+
* opencode reads its global config from EITHER `opencode.json` or `opencode.jsonc` under
288+
* `~/.config/opencode`; both names are documented. So the installer must edit the one that is
289+
* already there: hardcoding `opencode.json` meant a user whose config is `opencode.jsonc` got a
290+
* SECOND config file created next to their real one — their settings in the file they wrote, our
291+
* plugin entry in a file they never made.
292+
*
293+
* `.jsonc` is probed first because that is the name that carries comments, and a machine holding
294+
* both is far likelier to have the commented one as the real config.
295+
*/
296+
export const OPENCODE_CONFIG_CANDIDATES = ["opencode.jsonc", "opencode.json"];
297+
298+
function opencodeConfigPath(c: InstallCtx): string {
299+
const dir = join(c.home, ".config", "opencode");
300+
const existing = OPENCODE_CONFIG_CANDIDATES.map((f) => join(dir, f)).find((p) => existsSync(p));
301+
return existing ?? join(dir, "opencode.json");
302+
}
303+
246304
const opencode: HarnessInstaller = {
247305
name: "opencode",
248306
detect: (c) => onPath("opencode") || existsSync(join(c.home, ".config", "opencode")),
249307
install(c) {
250-
const path = join(c.home, ".config", "opencode", "opencode.json");
251-
const cfg = readJson(path);
252-
const plugins: string[] = Array.isArray(cfg.plugin) ? cfg.plugin : [];
253-
cfg.plugin = [...plugins.filter((p) => !String(p).includes(MARKER)), c.pkgRoot];
254-
writeJson(path, cfg);
308+
const path = opencodeConfigPath(c);
309+
let cfg: Record<string, any> = {};
310+
if (existsSync(path)) {
311+
const parsed = parseJsonc(readFileSync(path, "utf8"));
312+
if (!parsed) {
313+
c.log?.(`opencode: SKIPPED — could not parse ${path}; add the plugin entry manually`);
314+
return;
315+
}
316+
cfg = parsed;
317+
}
318+
const plugins: unknown[] = Array.isArray(cfg.plugin) ? cfg.plugin : [];
319+
// writeJsonc, not writeJson: this config routinely carries comments, and re-serializing the
320+
// parsed object would strip every one of them even though the read succeeded.
321+
writeJsonc(path, "plugin", [...plugins.filter((p) => !String(p).includes(MARKER)), c.pkgRoot]);
255322
c.log?.(`opencode: plugin registered in ${path}`);
256323
},
257324
uninstall(c) {
258-
const path = join(c.home, ".config", "opencode", "opencode.json");
325+
const path = opencodeConfigPath(c);
259326
if (!existsSync(path)) return;
260-
const cfg = readJson(path);
261-
if (Array.isArray(cfg.plugin)) {
262-
cfg.plugin = cfg.plugin.filter((p: string) => !String(p).includes(MARKER));
263-
if (!cfg.plugin.length) delete cfg.plugin;
264-
writeJson(path, cfg);
265-
}
327+
const cfg = parseJsonc(readFileSync(path, "utf8"));
328+
if (!cfg || !Array.isArray(cfg.plugin)) return;
329+
const kept = cfg.plugin.filter((p: unknown) => !String(p).includes(MARKER));
330+
writeJsonc(path, "plugin", kept.length ? kept : undefined);
266331
c.log?.("opencode: plugin entry removed");
267332
},
268333
};
@@ -348,18 +413,18 @@ const kilo: HarnessInstaller = {
348413
}
349414
const entry = pathToFileURL(join(c.dist, "kilo.js")).href;
350415
const plugins: unknown[] = Array.isArray(cfg.plugin) ? cfg.plugin : [];
351-
cfg.plugin = [...plugins.filter((p) => !String(p).includes(MARKER)), entry];
352-
writeJson(path, cfg);
416+
// writeJsonc for the same reason the read uses parseJsonc: kilo.jsonc is a commented file, and
417+
// re-serializing the parsed object would strip what we just took care to read.
418+
writeJsonc(path, "plugin", [...plugins.filter((p) => !String(p).includes(MARKER)), entry]);
353419
c.log?.(`kilo: plugin registered in ${path}`);
354420
},
355421
uninstall(c) {
356422
const path = kiloConfigPath(c);
357423
if (!existsSync(path)) return;
358424
const cfg = parseJsonc(readFileSync(path, "utf8"));
359425
if (!cfg || !Array.isArray(cfg.plugin)) return;
360-
cfg.plugin = cfg.plugin.filter((p: unknown) => !String(p).includes(MARKER));
361-
if (!cfg.plugin.length) delete cfg.plugin;
362-
writeJson(path, cfg);
426+
const kept = cfg.plugin.filter((p: unknown) => !String(p).includes(MARKER));
427+
writeJsonc(path, "plugin", kept.length ? kept : undefined);
363428
c.log?.("kilo: plugin entry removed");
364429
},
365430
};

hindsight-integrations/coding-agents/tsup.config.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,5 +58,13 @@ export default defineConfig({
5858
// hindsight-all (the local-daemon lifecycle manager) is inlined for the same reason: hooks are
5959
// wired by absolute path to ONE dist file and never load the package's node_modules. It has zero
6060
// dependencies of its own, so inlining costs almost nothing.
61-
noExternal: [/^@modelcontextprotocol\/sdk/, /^zod/, /^@vectorize-io\/hindsight-all/],
61+
// jsonc-parser likewise: installer.js is staged to ~/.hindsight/coding-agents as dist + skill +
62+
// package.json — never node_modules — so an external import there is unresolvable, and re-running
63+
// `install` from the staged copy (the upgrade path) dies with ERR_MODULE_NOT_FOUND.
64+
noExternal: [
65+
/^@modelcontextprotocol\/sdk/,
66+
/^zod/,
67+
/^@vectorize-io\/hindsight-all/,
68+
/^jsonc-parser/,
69+
],
6270
});

0 commit comments

Comments
 (0)