Skip to content

fix(coding-agents): stop opencode's install from replacing a JSONC config - #3843

Open
etnperlong wants to merge 5 commits into
vectorize-io:mainfrom
etnperlong:fix/opencode-jsonc-config
Open

fix(coding-agents): stop opencode's install from replacing a JSONC config#3843
etnperlong wants to merge 5 commits into
vectorize-io:mainfrom
etnperlong:fix/opencode-jsonc-config

Conversation

@etnperlong

Copy link
Copy Markdown

Summary

opencode reads its global config from either ~/.config/opencode/opencode.json or ~/.config/opencode/opencode.jsonc. The installer knew only the first name, and read it with readJson, whose JSON.parse rejects the comments and trailing commas a hand-maintained config carries, and whose catch returns {}. My config is opencode.jsonc, so install opencode wrote a second config file next to the real one. Had it been named .json, the {} fallback would have gone back to disk carrying only our plugin key, taking the providers, MCP servers and agent overrides with it. The <file>.hindsight-backup copy that writeJson takes on first touch is the only reason a config in that state is recoverable.

Five commits, each one step:

  1. opencodeConfigPath probes opencode.jsonc then opencode.json, edits whichever already exists, and creates opencode.json only when neither does. Same shape as kiloConfigPath.
  2. The adapter parses with parseJsonc and aborts with a SKIPPED log on a file it cannot read, rather than falling back to {}. kilo already did this.
  3. writeJsonc sets or deletes one top-level key through jsonc-parser, so text it did not touch survives byte for byte. Parsing was only half of it: a config that read correctly still came back stripped, because writeJson round-trips through JSON.stringify.
  4. opencode writes through writeJsonc.
  5. kilo does too. It had the JSONC-aware read and the lossy write, and its test asserted the loss ("comments dropped, DATA kept"). This commit also adds the parity guard.

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; without it, re-running install from the staged copy exits with ERR_MODULE_NOT_FOUND. I hit that while testing and it is why the dependency is inlined.

Comments inside the plugin array are still lost, since that value gets re-emitted. Everything outside it is kept.

No issue filed for this. I ran into it installing the plugin on my own machine.

Type of change

  • Feature
  • Bug fix
  • Hotfix
  • Spike / exploration
  • Documentation
  • Refactor

Test plan

  • src/installer.test.ts: opencode gains coverage for editing an existing .jsonc, uninstalling from the same file, creating .json when neither exists, keeping a commented config's other keys, parsing a trailing-comma .json, and leaving an unparseable file alone. I checked these against the pre-fix installer: three of them fail there with the reported symptom.
  • A parity block drives a real install and uninstall for every JSONC host and asserts the comments survive both. Reverting either adapter to writeJson makes it fail, which is how I confirmed it would have caught the kilo half.
  • The kilo test that asserted comments were dropped now asserts they are kept.
  • npm test: 669 passed across 55 files. npx tsc --noEmit and npm run build clean. ./scripts/hooks/lint.sh clean.
  • Ran the built installer against a copy of my real opencode.jsonc (4.7 KB, four providers, ten agent overrides, comments throughout): no competing opencode.json, and diff shows changes confined to the plugin array.

Notes for the reviewer

  • parseJsonc is now used for opencode.json as well as .jsonc. Comments turn up under both names, so the file content decides the parser rather than the extension. If you would rather keep strict parsing for .json, that is a one-line change, but it leaves the original data loss in place for anyone whose commented config uses that name.
  • writeJsonc takes a single key instead of an object on purpose. A whole-object write is exactly what forces the re-serialize, and both callers only ever touch plugin.
  • Strict-JSON hosts (claude-code, cursor, devin, and the rest) and the TOML and YAML ones stay on writeJson. The parity test lists the JSONC hosts explicitly so a new one has to be added there.
  • OPENCODE_CONFIG_CANDIDATES is exported to mirror KILO_CONFIG_CANDIDATES. Neither is imported anywhere else.

opencode reads its global config from either `~/.config/opencode/opencode.json`
or `~/.config/opencode/opencode.jsonc`, but the installer hardcoded the `.json`
name. A user whose config is `opencode.jsonc` therefore got a SECOND config file
created beside their real one: their settings in the file they wrote, our plugin
entry in a file they never made.

`opencodeConfigPath` now probes the same way `kiloConfigPath` does — edit the
candidate that already exists, and only create `opencode.json` when neither
does. `.jsonc` is probed first because that is the name that carries comments,
so a machine holding both most likely keeps the real config there.

This is the path resolution only; the strict-JSON parse of a commented file is
still broken and is fixed next.
…onfig

The opencode adapter read its config with `readJson`, whose strict `JSON.parse`
rejects the comments and trailing commas real configs carry — and whose `{}`
fallback then meant the whole file was rewritten as just our plugin key. A user
with providers, agent overrides and MCP servers configured lost all of it to an
install that reported success.

It now parses with `parseJsonc` and aborts on a file it cannot understand,
exactly as the kilo adapter already does: a config we merely failed to read must
never be replaced by our own key alone. The backup `writeJson` takes on first
touch was the only reason this was recoverable at all.

Note this applies `parseJsonc` to `opencode.json` too, not just `.jsonc` — the
comments turn up under both names, so the content decides the parser, not the
extension.
Parsing a JSONC config was only half the problem. `writeJson` round-trips the
whole object through `JSON.stringify`, which can only emit strict JSON — so even
a config we read correctly came back with every comment and trailing comma
stripped and its formatting reflowed. For a hand-maintained config that reads as
damage regardless of the data surviving.

`writeJsonc` computes a minimal text edit with jsonc-parser instead, so anything
it did not touch survives byte for byte, CRLF included. It sets or deletes ONE
top-level key on purpose: a whole-object write is exactly what forces the
re-serialize, and both JSONC callers only ever mutate `plugin`. Comments inside
the edited value are still lost, since that value is re-emitted.

jsonc-parser is added to tsup's noExternal list. installer.js is staged to
~/.hindsight/coding-agents as dist + skill + package.json and never
node_modules, so an external import is unresolvable there — verified by running
the built installer from a staged-layout copy, which failed with
ERR_MODULE_NOT_FOUND before the change and runs after it.

Unused until the next commit, which routes opencode through it.
…ments

opencode's adapter now writes through `writeJsonc`, so installing or removing
the plugin entry edits the `plugin` key and leaves the rest of the file exactly
as the user wrote it — comments, trailing commas and formatting included.

Before this it parsed correctly (previous commit) but still round-tripped
through JSON.stringify on the way out, so a commented config came back stripped:
the data survived, the file the user maintains did not.

Uninstall takes the same path, passing `undefined` to drop the key when no other
plugin entries remain, which is what the object-delete used to express.
…NC family

kilo had the same split as opencode: a JSONC-aware `parseJsonc` on the way in,
`writeJson` on the way out. So a commented `kilo.jsonc` was read correctly and
then written back stripped — the existing test even asserted it ("comments
dropped, DATA kept"), which is how the loss stayed invisible.

It now writes through `writeJsonc`, and that test asserts the comment survives.

The parity guard sweeps the JSONC hosts instead of trusting a per-harness test:
this bug existed in two adapters at once and would return with the third, since
the sibling that forgets is by construction the one nobody wrote a test for. It
drives a real install and uninstall against a commented config and checks the
file that came out. Verified it fails when either adapter is reverted to
`writeJson`.

Strict-JSON hosts (claude-code, cursor, …) and the TOML/YAML ones deliberately
stay on `writeJson`, and the list says so.
@strix-security

strix-security Bot commented Aug 27, 2026

Copy link
Copy Markdown

Strix Security Review

No security issues found.

Updated for b9a6ec1.


Reviewed by Strix
Re-run review · Configure security review settings

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant