Skip to content

[Feature]: opencode config directory mirror + verbatim provisioning #38

Description

@neurolabs

Motivation

Today the opencode/ snippet directories (~/.config/opencode-sandbox/opencode/ and .opencode-sandbox/opencode/) serve a single purpose: every *.json/*.jsonc/*.json5 file under them is deep-merged into one opencode.json provisioned at /home/dev/.config/opencode/opencode.json in the VM.

This conflation is a latent bug and a limitation:

  • A user who wants to ship other opencode runtime files (e.g. tui.json, AGENTS.md, agents/**, commands/**, plugins/**, themes/**) has no dedicated mechanism: those files are either merged incorrectly into opencode.json (any JSON file) or must be wired through the separate home.yaml manifest.
  • auth.json (credentials) lives in ~/.local/share/opencode/ and is already out of scope; it stays on the existing home.yaml path.

Proposed behavior

Make the opencode/ directory a verbatim mirror of the VM's ~/.config/opencode/ directory, while keeping the opencode.json snippet-merge behavior in an explicit, well-separated subdirectory.

  • Treat the top level of opencode/ as a 1:1 verbatim mirror of ~/.config/opencode/: every file and subdirectory is provisioned verbatim, preserving its relative path.
  • Keep deep-merging of opencode.json snippets, but relocate it into a dedicated subdirectory (opencode/config-snippets/) so it cannot collide with verbatim files.
  • A top-level file named opencode.json/opencode.jsonc/opencode.json5 remains special: it is deep-merged (treated as a snippet), never copied verbatim. The merged output always wins over any raw copy.
  • Fix the latent bug where a non-opencode.json JSON file (e.g. tui.json) at the top level was wrongly merged into opencode.json.
  • Document the resulting breaking change and provide a migration note.

Non-goals

  • Provisioning ~/.local/share/opencode/ (e.g. auth.json) — stays on the home.yaml mechanism.
  • Any write-back / sync of auth.json from the VM to the host.
  • A share/ mirror directory.
  • Interactive-login flows inside the VM.

Alternatives considered

  • Keep only the home.yaml manifest for non-opencode.json files — works but adds per-file wiring and doesn't fix the merge-collision bug for JSON files at the top level.
  • A dedicated share/ mirror alongside config/ — rejected as out of scope (see non-goals).

Design spec

Full design spec (2026-08-28-opencode-config-mirror-design.md)

Design: opencode config directory mirror + verbatim provisioning

Date: 2026-08-28
Status: Draft (pending review)

Problem

Today the opencode/ snippet directories (~/.config/opencode-sandbox/opencode/ and
.opencode-sandbox/opencode/) serve a single purpose: every *.json/*.jsonc/*.json5 file under them is
deep-merged into one opencode.json provisioned at /home/dev/.config/opencode/opencode.json in the VM.

This conflation is a latent bug and a limitation:

  • A user who wants to ship other opencode runtime files (e.g. tui.json, AGENTS.md, agents/**,
    commands/**, plugins/**, themes/**) has no dedicated mechanism: those files are either merged
    incorrectly into opencode.json (any JSON file) or must be wired through the separate home.yaml
    manifest.
  • auth.json (credentials) lives in ~/.local/share/opencode/ and is already out of scope; it stays on
    the existing home.yaml path.

We want the opencode/ directory to act as a verbatim mirror of the VM's ~/.config/opencode/
directory, while keeping the opencode.json snippet-merge behavior in an explicit, well-separated
subdirectory.

Goals

  • Treat the top level of opencode/ as a 1:1 verbatim mirror of ~/.config/opencode/ in the VM:
    every file and subdirectory is provisioned verbatim, preserving its relative path.
  • Keep deep-merging of opencode.json snippets, but relocate it into a dedicated subdirectory
    (opencode/config-snippets/) so it cannot collide with verbatim files.
  • A top-level file named opencode.json/opencode.jsonc/opencode.json5 remains special: it is
    deep-merged (treated as a snippet), never copied verbatim. The merged output always wins over any
    raw copy.
  • Fix the latent bug where a non-opencode.json JSON file (e.g. tui.json) at the top level was
    wrongly merged into opencode.json.
  • Document the resulting breaking change and provide a migration note.

Non-goals

  • Provisioning ~/.local/share/opencode/ (e.g. auth.json). This remains the responsibility of the
    existing home.yaml mechanism.
  • Any write-back / sync of auth.json from the VM to the host.
  • A share/ mirror directory.
  • Interactive-login flows inside the VM.

Context (verified)

  • internal/opencodeconfig/opencodeconfig.go implements the snippet merge: scanSnippets reads every
    json/jsonc/json5 file in the given dirs (user first, then project; alphabetical within each) and
    deepMerges them into one map; BuildOpenCodeJSON(userDir, projectDir) returns the marshaled
    opencode.json plus the ordered source list and a "has snippets" boolean.
  • internal/sandbox/reprovision/config_files.go (LoadConfigFiles) builds the desired VM state: it
    calls config.BuildOpenCodeJSON(userConfigDir, projectOpenCodeDir) for the merged config and
    homeconfig.BuildHomeFiles(...) for home.yaml files, returning a ConfigFiles struct
    (HasSnippets, OpenCode, HomeFiles map[string][]byte, Hooks, Keys).
  • internal/sandbox/reprovision/reprovision.go writes the merged opencode.json to
    /home/dev/.config/opencode/opencode.json (OpenCodeConfigPath(VMHomeDir)) and then each
    HomeFiles entry to its VM path.
  • OpenCodeConfigPath (config_files.go:60) is the single reserved VM path for the merged config.
  • configpaths: UserOpencodeConfigDir() = ~/.config/opencode-sandbox/opencode;
    ProjectOpencodeConfigDir() = .opencode-sandbox/opencode. Both currently feed the merge.
  • opencode's ~/.config/opencode/ holds opencode.json (config), tui.json/tui.jsonc (TUI),
    AGENTS.md (global rules), and extension subdirs agents/, commands/, plugins/, skills/,
    themes/, tools/. It does not hold auth.json (that lives in ~/.local/share/opencode/).

Design

Source layout

Under ~/.config/opencode-sandbox/opencode/ (user) and .opencode-sandbox/opencode/ (project):

  • Top level — verbatim mirror of ~/.config/opencode/. Every file and directory is provisioned
    verbatim, preserving its relative path, except files named opencode.json, opencode.jsonc, or
    opencode.json5 (the opencode.json(|c|5) exception pattern).
  • opencode/config-snippets/ — dedicated snippet subdirectory, excluded from the verbatim
    copy. Files named opencode.json/opencode.jsonc/opencode.json5 here are deep-merged into the
    final opencode.json.

Merge logic (final opencode.json)

The merged opencode.json is the deep merge of:

  1. opencode/config-snippets/*opencode.json(c|5)* — user dir merged first, then project dir; within
    each dir alphabetical (unchanged merge order semantics).
  2. A top-level opencode/opencode.json/.jsonc/.json5, if present — deep-merged into the same
    result.

Merged wins over verbatim: the merged output is always what is provisioned at
~/.config/opencode/opencode.json; a raw verbatim opencode.json is never copied. A top-level
opencode.json "undergoes normal deep merging" — it is treated as another snippet. If a user also
places a file that would verbatim-copy to opencode.json, it is not copied; instead it is folded into
the merge.

Provisioning & precedence

  • ~/.config/opencode/opencode.json remains reserved for the merged output only.
  • Verbatim mirror files are provisioned alongside home files on startup (same path as
    HomeFiles), with an explicit home.yaml target winning over the mirror for the same VM path
    (explicit beats implicit).
  • config-snippets/ is never copied verbatim; only its opencode.json(c|5) files are merged.

Implementation approach

Modify internal/opencodeconfig to:

  • Introduce a notion of the reserved exception pattern: only files whose basename is exactly
    opencode.json, opencode.jsonc, or opencode.json5 (case-insensitive extension) participate in
    the merge. This replaces today's "every json/jsonc/json5 file is a snippet" rule.
  • Add a function to scan the verbatim mirror: walk the top-level opencode/ dirs (user + project,
    project overriding user per path), excluding the config-snippets/ subdir and the
    opencode.json(|c|5) exception files, and return the set of verbatim file paths/content keyed by
    VM path under ~/.config/opencode/.

Modify internal/sandbox/reprovision/config_files.go:

  • Extend ConfigFiles to carry the verbatim mirror files (either merged into HomeFiles or a new
    field) and the reserved opencode.json handling.
  • Update LoadConfigFiles to (a) merge config-snippets/ + top-level opencode.json(c|5), and
    (b) collect the verbatim mirror files.

reprovision.go already writes OpenCodeConfigPath and HomeFiles; the verbatim mirror files flow
through the same write path. Home-file comparison logic (OpenCodeConfigEqual / config comparison)
must account for the new files.

Secrets note

auth.json is out of scope (see Non-goals). No secret-warning logic is added in this change; the
verbatim mirror is a general-purpose copy mechanism. If a future need arises for detecting raw secrets
in provisioned credential files, it will be designed separately.

Breaking change (document)

  • Today any top-level opencode/*.json* file is merged into opencode.json. Under the new model the
    top level is a verbatim mirror, so:
    • Existing snippet files must move into opencode/config-snippets/.
    • A top-level opencode.json(c|5) is now merged (treated as a snippet), not copied verbatim.
    • A top-level non-opencode.json JSON file (e.g. tui.json) is now copied verbatim instead of
      being merged — this is the fix to the latent bug.
  • Document the new layout and migration in README/docs and CHANGELOG.md [Unreleased].

Testing

  • Unit tests in internal/opencodeconfig: merge of config-snippets/; top-level opencode.json
    folded into the merge; non-opencode.json top-level JSON excluded from the merge; verbatim mirror
    scan excluding config-snippets/ and the exception pattern.
  • CLI test(s) in cmd/opencode-sandbox/cli_*_test.go covering any new/affected flags or config
    subcommands (e.g. config show output for the new layout).
  • config_compare_test.go / reprovision tests updated for the new merge and mirror semantics.
  • make check passes (fmt, lint, test).

Documentation & changelog

  • README.md and docs/configuration.md (Opencode configuration section): describe the new
    config-snippets/ subdir, the top-level verbatim mirror, the opencode.json(|c|5) exception, and
    the breaking-change migration.
  • CHANGELOG.md: add an [Unreleased] entry for the layout change.

Files touched (anticipated)

  • internal/opencodeconfig/opencodeconfig.go — merge rule + verbatim mirror scanner.
  • internal/opencodeconfig/opencodeconfig_test.go — tests.
  • internal/sandbox/reprovision/config_files.goConfigFiles/LoadConfigFiles for mirror files.
  • internal/sandbox/reprovision/config_files_test.go / coverage_test.go / config_compare_test.go
    — tests.
  • cmd/opencode-sandbox/ — any CLI test coverage for config show etc.
  • README.md, docs/configuration.md, CHANGELOG.md — docs and changelog.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions