Skip to content

fix(cli): leave a user-owned OpenCode module untouched instead of appending the block - #2641

Open
crisacm wants to merge 1 commit into
DeusData:mainfrom
crisacm:fix/2204-opencode-extension-untouched
Open

crisacm wants to merge 1 commit into
DeusData:mainfrom
crisacm:fix/2204-opencode-extension-untouched

Conversation

@crisacm

@crisacm crisacm commented Oct 14, 2026 •

Copy link
Copy Markdown

Summary

The generated OpenCode adapter (~/.config/opencode/plugins/cbm-augment.ts) is an auto-loaded module, so it is installed as a managed block rather than a whole-file write. That is deliberate: the directory is auto-loaded, so a user may legitimately keep their own module there.

But when the file on disk carried content and none of our markers, the install appended our block to it anyway — even though the block's own header tells readers to "remove the markers to take ownership". That contradicts the promise and produces a file the client cannot parse.

Reproduction

install -y against 0.11.0, in an isolated HOME, starting from the hand-port shape described in #2204:

// the user's own module: content, no markers
import { spawn } from 'node:child_process';
const BIN = '/Users/x/.local/bin/codebase-memory-mcp';
function augment(payload) { /* … */ }
export default { id: 'codebase-memory-augment', setup() {} };

After the run the managed block sits after that content, so module-level names are declared twice:

SyntaxError: Identifier 'spawn' has already been declared

OpenCode's loader then drops the whole extension and the only trace is a load warning. Once the block itself carries export default (#2089), the same append turns into a duplicate default export instead. So a hand-port is not a durable end state — which is why #2385 matters — but the installer should not manufacture the broken file in the meantime.

Change

cbm_text_managed_block_status() — a read-only ownership probe: 0 writable (absent, whitespace-only, or already ours), 1 the file carries content but none of our markers, -1 unsafe state or I/O failure.

install_generated_client_extension() probes before writing:

  • writable — unchanged behaviour: install, or refresh the block in place.
  • foreign — leave the file exactly as it is and report it, in the real run and in --dry-run. The probe runs before generation so the preview names the refusal instead of promising a write the run will not make (the same honesty rule install on 0.9.1-rc.1 removes existing Claude Code hooks instead of installing them #1387 established for hook scripts).
  • probe error — falls through to the existing write, which reports it through the usual error path.

cbm_text_upsert_managed_block() and its other callers (instruction documents, where appending to a user's file is the intent) are untouched — the new semantics are scoped to the auto-loaded module case.

If you would rather the run fail loudly than skip, that is a small change to the same call site; I picked "leave alone and say so" because it matches the ownership wording in the generated header.

Verification

End-to-end in isolated HOMEs on the built binary:

on-disk file before after
foreign module, no markers appended → SyntaxError left untouched, reported
no file at all created created (92 lines, markers present)
stale block, markers present replaced refreshed in place

Tests:

  • scripts/test.sh --suites config_text_edit — 23 passed, including the new config_text_managed_block_status_reports_foreign_module (absent / empty / whitespace / ours / foreign / unbalanced markers / malformed markers).

  • scripts/test.sh --suites cli — 361 passed, 1 failed, and the new cli_extension_leaves_user_owned_module_untouched_issue2204 is the +1 over the baseline: it seeds a user-owned module, runs the dry run, and asserts both the warning and that the file is byte-identical afterwards (modelled on the existing cli_dry_run_predicts_refused_hook_script_issue1387).

  • The single failure is pre-existing and unrelated. Same machine, same suite, same test:

    tree result
    main at 0ca6e7d (no changes) 360 passed, 1 failed
    this branch 361 passed, 1 failed

    In both trees the failing test is cli_registry_installs_gitlab_and_devin_lifecycle_context with the identical diagnostic plan=1 clean_plan=1 installed=0 cleaned=1 gitlab=1 devin=1 uninstall=0 — it touches neither the OpenCode adapter nor the managed-block writer. Flagging it here so a red test-unix job is not read as fallout from this change.

  • clang-format 20.1.8 (CI's pinned version) reports no changes to any touched file.

Refs #2204. The template side — registering the 2.x hooks in setup — is #2385 and is deliberately not touched here. Happy to fold this into #2385 instead if you would rather keep the OpenCode changes on one branch.

…ending the block

The generated OpenCode adapter is an auto-loaded module, so it is installed as a
managed block rather than a whole-file write. When the file on disk carried
content but none of our markers, the upsert appended the block anyway, even
though the block's own header tells readers to "remove the markers to take
ownership".

The appended prelude redeclares module-level names the user's own module also
declares -- `import { spawn }`, `const BIN`, `function augment` -- and both
sides declare a default export once the block itself carries one, so the result
cannot be parsed at all:

    SyntaxError: Identifier 'spawn' has already been declared

The client then drops the whole extension with only a load warning to show for
it. A hand-port around DeusData#2204 is therefore not a durable end state.

Probe block ownership before writing. A marker-less file that carries content is
left alone and reported in both the real run and the dry run, instead of being
turned into an unparseable hybrid. An absent, empty, whitespace-only, or
already-ours file still installs or refreshes exactly as before.

Refs DeusData#2204

Signed-off-by: Cristian Castillo <c.a.c.m997@gmail.com>
@crisacm
crisacm requested a review from DeusData as a code owner October 14, 2026 16:20
@github-actions

Copy link
Copy Markdown

Thanks for opening this — it has been seen, and it is queued.

This note is automated, but it is not a brush-off: it exists so you know where your PR stands instead of having to guess from silence.

Current review status: working through a backlog. 0.9.1-rc.1 is out, so the release freeze that held reviews is over — but it left a large queue of open pull requests behind it, and we are reading through them oldest-first. The background is in discussion #1144.

What that means for this PR, concretely:

  • It will not be closed for inactivity. No stale bot touches pull requests here.
  • It may still sit a while before a human reads it. That is on us, not on you.
  • Older PRs are read first, so a recent one is not being skipped — it is behind a queue.

Things that will genuinely speed it up whenever review does happen:

  • Keep it rebased on main — the tree is moving quickly right now, and a conflicting branch cannot be reviewed as the diff you intended.
  • Get CI green, or say which failures you believe are pre-existing.
  • Keep the change to one claim. Bundled features and refactors get split before they get merged, which costs you a round trip.
  • Every commit needs a sign-off (git commit -s) — CI enforces DCO.

If this fixes a bug, a reproduction we can run is worth more than a description of the symptom.

Thanks for contributing, and sorry in advance for the wait.

This branch has not been deployed

No deployments
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