Skip to content

feat: OpenCode feature parity — plugin + skills + hooks - #616

Closed
ZeR020 wants to merge 2 commits into
DeusData:mainfrom
ZeR020:feature/opencode-parity
Closed

feat: OpenCode feature parity — plugin + skills + hooks#616
ZeR020 wants to merge 2 commits into
DeusData:mainfrom
ZeR020:feature/opencode-parity

Conversation

@ZeR020

@ZeR020 ZeR020 commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #585. Adds full OpenCode feature parity with Claude Code:

  1. TypeScript plugin (~/.config/opencode/plugins/cbm-augment.ts) — auto-discovered, no opencode.json entry needed
  2. Consolidated codebase-memory skill (~/.config/opencode/skills/codebase-memory/SKILL.md) — same skill content as Claude Code
  3. Uninstall path — removes plugin, skills, MCP config, and AGENTS.md

Design Decision: tool.execute.after (not .before)

Issue #585 proposed tool.execute.before for context injection. However, source verification against anomalyco/opencode proved that .before is non-functional for this purpose:

tool.execute.before (packages/opencode/src/session/tools.ts:82):

yield* plugin.trigger("tool.execute.before",
  { tool: item.id, sessionID, callID },  // input — NO args field
  { args },                                // output — fresh wrapper object
)
const result = yield* item.execute(args, ctx)  // uses ORIGINAL args
  • Input does NOT include args. Output { args } is a fresh object; mutation has zero effect — the tool uses the original args variable, not the mutated output.
  • Runs BEFORE the tool produces a result — there's nothing to append to.

tool.execute.after (packages/opencode/src/session/tools.ts:92):

const output = { ...result, attachments: [...] }
yield* plugin.trigger("tool.execute.after",
  { tool, sessionID, callID, args },  // input — INCLUDES args
  output,                               // output — actual tool result
)
return output  // model sees this
  • output.output (type: string) is model-visible tool result text.
  • Mutating output.output in place IS model-visible.
  • input.args available (has pattern for grep/glob).

This PR uses .after so that graph context is appended to the tool result the model actually sees.

How It Works

Plugin (cbm-augment.ts)

tool.execute.after hook:

  • Checks if input.tool is "grep" or "glob" (OpenCode uses lowercase tool names)
  • Maps to capitalized "Grep"/"Glob" for the agent-agnostic hook-augment binary
  • Spawns <binary> hook-augment, writes {"tool_name":"Grep","tool_input":{"pattern":"..."}} to stdin
  • Parses hookSpecificOutput.additionalContext from stdout
  • Appends to output.output with \n\n separator
  • All failures silently swallowed — never blocks or throws (consistent with Claude Code's cbm-code-discovery-gate)

experimental.chat.system.transform hook:

  • Pushes the session reminder string to output.system (rebuilt fresh each turn, no accumulation)
  • Message consistent with CMM_SESSION_REMINDER_CMD used by Codex/Gemini/Antigravity

Install/Uninstall

Install path (install_cli_agent_configs):

  • Calls cbm_install_skills(skills_dir, true, dry_run) — reuses existing function
  • Calls cbm_upsert_opencode_plugin(home, binary_path, dry_run) — writes plugin with embedded binary path
  • Plan mode (--plan) records skills + plugin entries without mutating

Uninstall path (uninstall_cli_agents):

  • Calls cbm_remove_skills(skills_dir, dry_run)
  • Calls cbm_remove_opencode_plugin(home, dry_run) — unlinks plugin file (unlike Claude Code which leaves inert scripts, OpenCode auto-discovers plugins so the file must be deleted)

Security

Tests

5 new tests in tests/test_cli.c:

  • cli_upsert_opencode_plugin_fresh — verifies plugin file written with correct binary path, hook names, and satisfies Plugin
  • cli_upsert_opencode_plugin_idempotent — verifies re-install overwrites cleanly with new path
  • cli_upsert_opencode_plugin_rejects_quote — verifies security: binary paths with " are rejected
  • cli_remove_opencode_plugin — verifies install → remove → file gone, and idempotent remove
  • cli_opencode_skills_installed — verifies SKILL.md written to correct path with correct content

All 5 pass. 5698 total tests pass. 1 pre-existing failure (incr_full_index in test_incremental.c) is unrelated — an RSS memory threshold assertion that's environment-dependent.

Files Changed

  • src/cli/cli.c (+149 lines): opencode_plugin_content static string, cbm_upsert_opencode_plugin(), cbm_remove_opencode_plugin(), install/uninstall path modifications
  • src/cli/cli.h (+9 lines): function declarations
  • tests/test_cli.c (+130 lines): 5 new tests + RUN_TEST registrations

Copilot AI review requested due to automatic review settings June 24, 2026 20:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@DeusData

Copy link
Copy Markdown
Owner

Huge thanks for opening this PR and for the work you put into it.

The maintainer shop is currently full, so this may sit for a bit before it gets a proper review. We will come back to this as soon as possible with real feedback; I wanted to make sure it did not sit unacknowledged in the meantime.

@DeusData DeusData added enhancement New feature or request editor/integration Editor compatibility and CLI integration priority/backlog Valuable contribution, lower scheduling urgency; review when maintainer capacity opens. labels Jun 29, 2026
@DeusData DeusData added this to the 0.9.2-rc milestone Jul 8, 2026
Add full OpenCode integration matching Claude Code parity:
- TypeScript plugin (tool.execute.after for grep/glob graph augmentation,
  experimental.chat.system.transform for session reminder)
- Consolidated codebase-memory skill installation
- Plugin auto-discovered from ~/.config/opencode/plugins/ (no config entry needed)
- Skill auto-discovered from ~/.config/opencode/skills/

Design note: uses tool.execute.after (NOT .before as proposed in DeusData#585)
because source verification against anomalyco/opencode proved .before output
mutation has no effect — the tool uses original args, not output.args.
The .after hook mutates output.output which is model-visible.

Non-blocking: all plugin failures silently swallowed (consistent with
Claude Code hook gate script behavior). TOCTOU-safe fchmod pattern.
Defensive binary-path quote rejection (security).

5 new tests: plugin install/idempotent/quote-reject/remove, skills install.

Closes DeusData#585

Signed-off-by: ZeR020 <ZeR020@users.noreply.github.com>
@ZeR020
ZeR020 force-pushed the feature/opencode-parity branch from 98a9684 to 7116a76 Compare July 13, 2026 07:52
@ZeR020
ZeR020 requested a review from DeusData as a code owner July 13, 2026 07:52
@DeusData

Copy link
Copy Markdown
Owner

Reviewed alongside the two other agent-install PRs in the queue, since all three occupy the same area. Half of this has shipped; the other half is a direction question.

The skills half is now on main. Commit a3903ca (12 July) rebuilt the client-surface machinery and installs the consolidated skill for OpenCode at ~/.config/opencode/skills plus an agent profile. So that part is covered.

The plugin half is genuinely novelgit log -S 'opencode/plugins' on main is empty, so nothing equivalent exists. And the design work behind it is good: your PR body proves .before is non-functional upstream by reference to the actual OpenCode source, which is exactly the kind of evidence that makes a reviewer's job easy. The tool.execute.afterhook-augmentadditionalContext path is a sensible shape.

Why the plugin is a maintainer call rather than a merge:

  • It ships a persistent executable artifact into a user's config directory that spawns our binary on every grep and glob. That is the point of the feature, but it is also precisely the class of artifact our recent antivirus work has been trimming, so it needs a deliberate yes.
  • It binds us to OpenCode's experimental. namespace (experimental.chat.system.transform) and to tool.execute.after output-mutation semantics verified against one upstream revision. Both can shift under us, and once shipped we support it indefinitely.

I have put that to the maintainer. The instruction content itself is clean — I checked specifically, since shipped agent-facing text is content an agent will follow, and yours is product-consistent steering with no goal manipulation and no leaked personal context.

Two defects to fix if it does go ahead:

  1. Raw fopen() in cbm_upsert_opencode_plugin. Our rule is cbm_fopen() (UTF-8 → _wfopen) for user-home paths — raw fopen breaks on a non-ASCII Windows home directory.
  2. Backslash is not escaped in the plugin template. You correctly reject binary paths containing ", but a Windows path like C:\Users\... gets embedded into the TypeScript string literal BIN = "%s" and produces mangled escapes. The quote check alone is not sufficient there.

Smaller: under --dry-run the plugin and skills success lines print unconditionally, and the if (!dry_run) wrapper around cbm_upsert_opencode_plugin is redundant since the function already handles dry-run itself.

Your tests are good — five round-trips covering fresh install, idempotent overwrite, quote rejection, and remove-then-remove-again. Worth saying, because install code is easy to leave untested.

Please hold off reworking until the direction answer arrives; the whole block needs re-basing onto the a3903ca structure and I would rather you did that once.

@DeusData

Copy link
Copy Markdown
Owner

Thank you for this, and I am sorry it took as long as it did to come back with a real answer.

The decision: OpenCode stays an MCP-only integration, so we are not taking the plugin. The reasoning is about what this project ships rather than about the quality of your work, and I want to give you the whole of it.

Why

codebase-memory-mcp is an MCP server, and today it ships exactly one kind of artifact into a user's config: text. Markdown, JSON, .sh, .cmd, .ps1. Zero .ts or .js files — I checked before deciding, rather than assuming. Taking this would make it the first, and it would be executable, auto-discovered by OpenCode with no user action, chmod 0755, and interpreted by a third-party runtime. Once one client gets a shipped JS artifact the others reasonably ask for one too, and that is a maintenance surface we do not want to own.

There is a second reason, specific to the mechanism: the plugin binds us to OpenCode's experimental.chat.system.transform and to in-place mutation of output.output, which you reverse-engineered from their source rather than from a contract. Every failure mode of that design is silent — if they rename or re-wrap either, the augmentation simply stops, with no error and no signal, and we find out through a support report. Owning a compatibility surface against another product's experimental API, on a default install path, is a real ongoing cost.

What OpenCode users already get

This is the part that made the decision easier, and it is worth stating plainly: OpenCode already has the full tool surface. cbm_upsert_opencode_mcp registers the server in ~/.config/opencode/opencode.json, so all fifteen tools are available today. Main also installs AGENTS.md, the skill, and three deny-by-default read-only agents.

So what your plugin adds is not capability — it is the automatic grep/glob reflex. Real value, but a narrower gap than it first appears, and the way we deliver that elsewhere is through each client's own native hook configuration (Claude Code, Codex, Copilot, Gemini, Factory each have one). OpenCode does not have an equivalent, which is exactly why you had to write a plugin. That is a gap in OpenCode's extension model, not something we want to route around by shipping a runtime artifact.

Two things you should know regardless

The skills half of your PR already shipped. Main installs the OpenCode skill and its uninstall path, with tests, and went further with an agent profile — mode: subagent, edit: deny, bash: deny. That happened independently while this was open, which is on our queue, not on you.

And the plugin does not currently run. I checked empirically rather than assuming: the payload sends {"tool_name":"Grep",...} with no hook_event_name, and current hook-augment requires that field and accepts Grep/Glob only under PreToolUse — so it produces zero bytes of output. This is bit-rot rather than an error on your part: at your merge base the hook keyed on tool_name alone, and main's multi-dialect hardening changed the contract while the PR waited. Worth knowing so you do not spend time debugging something that was working when you wrote it.

One more that would have bitten a Windows user: the const BIN = "%s" template rejects " but not \, so a path like C:\Users\urs\… produces a hard SyntaxError in an auto-loaded plugin directory — which is worse than the feature not working, because it can break OpenCode's plugin subsystem at session start. Mentioning it in case the code lives on in your own fork.

If the ground shifts

If OpenCode ever ships a declarative hook configuration like Claude Code's or Codex's, this becomes a straightforward addition and we would want it — I will come back to this PR and to you if that happens.

Thank you again. Reverse-engineering the hook points from their source, honouring abort signals, and scanning stdout for the last parseable JSON line rather than trusting the first are all the instincts of someone who has debugged this class of integration before.

@DeusData

Copy link
Copy Markdown
Owner

Following up because the decision changed.

The OpenCode augmentation is being implemented — #1392, with Co-Authored-By: ZeR020 on the commit.

When I closed this, the objection was shipping a .ts artifact from our repository. The maintainer has since asked for the capability on the condition that no .ts file lives in our tree, so the plugin is now generated at install time and written into ~/.config/opencode/plugins/cbm-augment.ts as a marked block — which keeps a user's own module in that auto-loaded directory intact.

Your hook-point research is what made this possible at all. Working out that tool.execute.after is where output can be augmented, from their source rather than from documentation, is the part that would have taken me longest.

Two defects from the original are closed by construction, and both are pinned by tests — mentioning them because they are worth knowing if the code lives on in your own fork:

  • The payload omitted hook_event_name. Current hook-augment requires it and accepts Grep/Glob only under PreToolUse, so the plugin emitted zero bytes — it had been a silent no-op since main's multi-dialect hardening changed the contract underneath you. The generated payload carries the field.
  • The path template rejected " but not \. A home like C:\Users\urs\bin produced an invalid unicode escape, so the whole plugin failed to parse — worse than not working, since a broken module in an auto-loaded directory can take out the plugin subsystem at session start. The generator escapes backslash, quote, newline and CR, and refuses to emit anything rather than produce a truncated literal.

One thing I recorded rather than papered over: tool.execute.after's ability to modify output is not in OpenCode's documented plugin contract — only tool.execute.before's argument mutation is. So if they change it, augmentation stops silently. We are accepting that knowingly, and it is written in a comment at the emitter so nobody rediscovers it as a mystery later. Your instinct to reverse-engineer it was sound; the risk is inherent to the extension point, not to your implementation.

Thank you for the original work, and for the care in it.

pull Bot pushed a commit to KornaAI/codebase-memory-mcp that referenced this pull request Aug 4, 2026
pi has no MCP client, and OpenCode has no declarative hook configuration --
verified against OpenCode's own plugin documentation, which states hooks are
available only through JavaScript/TypeScript plugin modules. For those two
clients a module is the only extension point.

We do not want to ship such a module as a repository asset. DeusData#534 proposed one
embedding 380 lines of TypeScript as C string literals and registering 7 of the
15 registry tools; every tool added afterwards would have been silently missing
for that client. Hand-maintained copies of the tool list have already produced
defects here (DeusData#1361, and the smoke-invariants count).

So the module is GENERATED from the live registry instead. cbm_client_adapter_pi
walks cbm_mcp_tool_count()/cbm_mcp_tool_name() and registers every tool, which
makes drift structurally impossible rather than merely discouraged: adding a
tool to TOOLS[] adds it to every generated adapter with no second edit. Nothing
in the repository is a .ts file.

Both emitters wrap their output in ownership markers so a caller can rewrite its
own block and leave a user-authored file alone.

Two defects from the prior proposals are closed by construction:

- Path escaping. DeusData#616's template rejected the double quote but not the
  backslash, so a Windows home like C:\Users\urs\bin produced an invalid
  unicode escape and the whole auto-loaded plugin failed to parse, which is
  worse than an absent plugin. cbm_client_adapter_escape_js escapes backslash,
  quote, newline and CR, and fails closed rather than emitting a truncated
  literal; generation aborts if the path cannot be escaped.
- The silent no-op. DeusData#616's payload omitted hook_event_name, which hook-augment
  requires and without which it accepts nothing, so the plugin emitted zero
  bytes for six weeks. The generated payload carries it and a test pins it.

Recorded honestly: the OpenCode emitter hooks tool.execute.after, whose ability
to modify a tool's output is NOT part of OpenCode's documented plugin contract
(only tool.execute.before's argument mutation is). If they change it the
augmentation stops with no error. That risk is accepted deliberately and the
emitter says so in a comment, so a future reader does not have to rediscover it.

This commit adds the generator and its tests only; wiring into the install and
uninstall routines follows separately so the two are reviewable apart.

Tests: every registry tool appears in the pi module (revert-checked by
simulating the 7-of-15 subset, which fails); Windows/quote/newline escaping and
its truncation boundary; the OpenCode payload carries hook_event_name and
registers no tools; NULL/empty binary paths generate nothing.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
pull Bot pushed a commit to KornaAI/codebase-memory-mcp that referenced this pull request Aug 4, 2026
Wires the registry-driven generators into install, plan and uninstall.

pi gets ~/.pi/agent/extensions/cbmem.ts, its only route to the graph -- pi has
no MCP client. OpenCode gets ~/.config/opencode/plugins/cbm-augment.ts, which
adds no tools (it already reaches all of them over MCP) and supplies only the
automatic graph lookup before a grep/glob that other clients get from their own
hook configuration. OpenCode has none; its plugin system is the only extension
point, verified against their plugin documentation.

Both go in as a MARKED BLOCK rather than a whole-file write. Those directories
are auto-loaded, so a user may legitimately keep their own module there, and an
install routine that clobbers it is destroying user content. Uninstall removes
only our block and leaves the rest of the file, matching every other uninstall
path here.

A generator returning NULL is a hard error rather than a skip. A silently absent
extension is precisely the failure that left the earlier proposal a no-op for six
weeks, so it must surface as an install error instead of a quiet success.

Note for reviewers: the generated body deliberately carries NO ownership
markers. cbm_text_upsert_managed_block adds them itself and rejects content that
already contains them -- an existing guard test (test_cli.c:6018) caught that
integration mistake before CI did, which is the test doing exactly its job.

Reimplements the ideas from two community PRs, both closed as superseded by this
work, with credit to their authors:

- DeusData#534 (@Tensorboyalive) proposed the pi extension. It embedded 380 lines of
  TypeScript as C string literals and registered 7 of the 15 registry tools;
  this generates from the registry so the surface cannot drift, and ships no .ts
  in the repository. Their marker-delimited editing, their uninstall test
  asserting the markers are GONE, and their defensive stdout parsing (take the
  last line that parses, not the first) are all carried forward here.
- DeusData#616 (@ZeR020) proposed the OpenCode plugin. Its payload omitted
  hook_event_name, which hook-augment requires, so it produced zero bytes; and
  its path template rejected the double quote but not the backslash, so a
  Windows home like C:\Users\urs\bin produced an invalid unicode escape and the
  auto-loaded plugin failed to parse. Both are closed by construction here and
  pinned by tests. Their hook-point research is what made the OpenCode side
  possible at all.

Tests: install writes both extensions and the dry run does not; uninstall
removes our block while preserving a user's own content in the same file.

Co-Authored-By: Tensorboyalive <Tensorboyalive@users.noreply.github.com>
Co-Authored-By: ZeR020 <ZeR020@users.noreply.github.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

editor/integration Editor compatibility and CLI integration enhancement New feature or request priority/backlog Valuable contribution, lower scheduling urgency; review when maintainer capacity opens.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Full OpenCode feature parity with Claude Code — pre-tool hook, session-start context, and skill installation

3 participants