feat(#1914): OpenCode native plugin integration (Option 1 file-copy) (#1923)

* feat(#1914): OpenCode native plugin integration (Option 1 file-copy)

Ship a native OpenCode plugin (.opencode/plugins/gsd-core.js) plus the
installer step that delivers it, so GSD's lifecycle hooks run on OpenCode.
OpenCode declares hooksSurface:'none', so GSD's hook scripts already ship to
<configDir>/hooks/ but nothing invokes them; the plugin bridges OpenCode's
event bus onto those scripts as subprocesses (prompt/read/worktree/workflow
guards, injection scanner, context monitor).

Distribution is Option 1 (file copy) per the #1914 triage decision: no
scripts.build rename, no prepare/prepack removal. package.json gains
main + .opencode in files[] for discovery.

Corrected against OpenCode's docs + loader source (not the reference branch):
- Auto-discovery globs {plugin,plugins}/*.{ts,js} — .cjs is never matched, so
  the installed adapter must be .js (config dir carries {"type":"commonjs"}).
- No opencode.json plugin-array patch — that array is npm-only; local files
  are auto-discovered.
- REPO_ROOT is resolved by walking up to the dir holding hooks/ + gsd-core/,
  correct for package tree, global install, and local install.
- Config-hook registration is gated (IS_PACKAGE_TREE) so it never
  double-registers commands/agents/skills already delivered by native copy.

Also fixes an incidental .gitignore drift: 8 ADR-1239 .cts-generated .cjs
artifacts were untracked-and-not-ignored (leak risk) — now ignored.

Tests: tests/opencode-plugin-adapter.test.cjs (14, pure helpers + real
subprocess bridge against stub hooks); golden-install-parity regenerated.
Green on Mac + Linux (gsd-test): 0 failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#1914): harden OpenCode plugin export shape + advisory accumulation (adversarial findings)

Address Codex adversarial-review findings:
- HIGH: export `{ id, server }` could trip OpenCode's loader
  (`for (entry of Object.values(mod)) getServerPlugin(entry)` throws on a
  non-extractable value). Make `id` NON-ENUMERABLE and assign module.exports
  from a variable (not a literal) so no string `id` is ever iterated — verified
  loader-safe under real import(pathToFileURL) (default + module.exports alias,
  both objects with .server; no bare id string).
- MEDIUM: sequential advisory hooks clobbered output.metadata._gsdAdvisory;
  now accumulate into an array.
- LOW: resolveRepoRoot fallback returned ".." while the comment said "../.." —
  aligned to "../.." (package-tree depth).

Tests: added a faithful loader-loop emulation (raw CJS + ESM namespace views),
advisory-accumulation, and a real bin/install.js copy→manifest→uninstall
integration test. golden regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#1914): add changeset fragment for OpenCode plugin integration (PR #1923)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#1914): Windows — assert rewritten path with string include, not path-regex

The Read content-rewrite test built a RegExp from `path.join(root,'gsd-core')`.
On Windows the backslashes in the path are interpreted as regex escapes, so the
assertion never matched and `test (windows-latest, 24)` failed — even though the
adapter rewrote the path correctly. Replace the RegExp with a separator-agnostic
`String.includes` check (the repo's no-path-literal-in-assert concern).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-02 13:01:51 -04:00
committed by GitHub
parent 7bef6a6496
commit ff6b5cb024
8 changed files with 1118 additions and 1 deletions

View File

@@ -7316,6 +7316,22 @@ function uninstall(isGlobal, runtime = 'claude') {
}
}
// 4z. Remove the OpenCode native plugin adapter (#1914). Only GSD's own
// plugin file is removed; the plugins/ dir is pruned only if it becomes
// empty, preserving any user-authored OpenCode plugins.
if (isOpencode) {
const pluginsDir = path.join(targetDir, 'plugins');
const pluginPath = path.join(pluginsDir, 'gsd-core.js');
if (fs.existsSync(pluginPath)) {
try {
fs.unlinkSync(pluginPath);
removedCount++;
console.log(` ${green}✓${reset} Removed OpenCode plugin`);
} catch (_) { /* best-effort */ }
try { fs.rmdirSync(pluginsDir); } catch (_) { /* not empty — user plugins present */ }
}
}
// 4a. Remove scripts/changeset/ and scripts/lib/ (#935)
// GSD-managed files only: enumerate the exact set the installer writes.
// Any file NOT in this set is user-owned and must survive uninstall.
@@ -8064,6 +8080,15 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
manifest.files['scripts/fix-slash-commands.cjs'] = fileHash(fixSlashInstallPath);
}
// Track the OpenCode native plugin adapter (#1914) so update/drift detection
// and uninstall can account for it.
if (isOpencode) {
const pluginInstallPath = path.join(configDir, 'plugins', 'gsd-core.js');
if (fs.existsSync(pluginInstallPath)) {
manifest.files['plugins/gsd-core.js'] = fileHash(pluginInstallPath);
}
}
fs.writeFileSync(path.join(configDir, MANIFEST_NAME), JSON.stringify(manifest, null, 2));
return manifest;
}
@@ -8999,6 +9024,39 @@ function install(isGlobal, runtime = 'claude', options = {}) {
} else {
failures.push('skills/gsd-*');
}
// OpenCode-only: install the native plugin adapter (#1914). OpenCode
// declares hooksSurface: 'none', so GSD's lifecycle hooks are never
// registered as settings.json hooks the way Claude Code does — the hook
// *scripts* ship to <configDir>/hooks/ but nothing invokes them. This
// plugin bridges OpenCode's event bus onto those existing hook scripts
// (prompt guard, read guard, injection scanner, context monitor, ...),
// spawning them as subprocesses. OpenCode auto-discovers plugin files under
// <configDir>/plugins/ at startup — no opencode.json registration needed
// (its `plugin` array is for npm packages, not local file paths).
//
// The file MUST land as `.js`: OpenCode's loader globs
// `{plugin,plugins}/*.{ts,js}` (verified against its source) — a `.cjs`
// extension would never be discovered. The config dir carries a
// `{"type":"commonjs"}` package.json (written above), so the `.js` file is
// interpreted as CommonJS, matching the adapter's module.exports/require.
// Kilo has no plugin surface, so this is gated to OpenCode only.
if (isOpencode) {
const pluginSrc = path.join(src, '.opencode', 'plugins', 'gsd-core.js');
const pluginDestDir = path.join(targetDir, 'plugins');
const pluginDest = path.join(pluginDestDir, 'gsd-core.js');
if (fs.existsSync(pluginSrc)) {
fs.mkdirSync(pluginDestDir, { recursive: true });
fs.copyFileSync(pluginSrc, pluginDest);
if (fs.existsSync(pluginDest)) {
console.log(` ${green}✓${reset} Installed OpenCode plugin (bridges GSD hooks)`);
} else {
failures.push('plugins/gsd-core.js');
}
} else {
failures.push('plugins/gsd-core.js');
}
}
} else if (isCline) {
// Cline local install: rules-based only — commands are embedded in .clinerules (generated below).
// No skills/commands directory needed for local installs.