* 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:
5
.changeset/proud-bears-roam.md
Normal file
5
.changeset/proud-bears-roam.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 1923
|
||||
---
|
||||
OpenCode now runs GSD's lifecycle safety hooks (prompt-injection guard, read-before-edit guard, injection scanner, worktree/workflow guards, context monitor) via a native plugin installed to `~/.config/opencode/plugins/gsd-core.js`. OpenCode declares `hooksSurface: 'none'`, so these hooks were previously inert; the plugin bridges OpenCode's event bus onto GSD's existing hook scripts. Installed automatically by `npx @opengsd/gsd-core --opencode` and removed on uninstall.
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -69,6 +69,14 @@ build/
|
||||
/tsconfig.build.tsbuildinfo
|
||||
/gsd-core/bin/lib/host-integration.cjs
|
||||
/gsd-core/bin/lib/install-engine.cjs
|
||||
/gsd-core/bin/lib/embedding-adapter.cjs
|
||||
/gsd-core/bin/lib/adapter-declarative.cjs
|
||||
/gsd-core/bin/lib/adapter-imperative.cjs
|
||||
/gsd-core/bin/lib/model-adapter.cjs
|
||||
/gsd-core/bin/lib/hook-bus.cjs
|
||||
/gsd-core/bin/lib/state-io.cjs
|
||||
/gsd-core/bin/lib/mcp-server.cjs
|
||||
/gsd-core/bin/lib/external-descriptor-trust.cjs
|
||||
/gsd-core/bin/lib/cli-skew-check.cjs
|
||||
/gsd-core/bin/lib/capability-loader.cjs
|
||||
/gsd-core/bin/lib/capability-source.cjs
|
||||
|
||||
699
.opencode/plugins/gsd-core.js
Normal file
699
.opencode/plugins/gsd-core.js
Normal file
@@ -0,0 +1,699 @@
|
||||
/**
|
||||
* GSD plugin for OpenCode.ai (CommonJS)
|
||||
*
|
||||
* Architecture: SUBPROCESS REUSE. Instead of re-implementing hook logic inside
|
||||
* the plugin, this file is a thin adapter that spawns the existing Claude Code
|
||||
* hook scripts under hooks/ as child processes. The hooks speak a stable
|
||||
* protocol (JSON on stdin, JSON + exit code on stdout); this adapter:
|
||||
* 1. Translates OpenCode plugin events into Claude Code hook payloads
|
||||
* 2. Spawns `node <HOOKS_DIR>/<hook>.js` with the payload on stdin
|
||||
* 3. Translates hook output back into OpenCode semantics
|
||||
* - block → throw Error (OpenCode returns the error to the model)
|
||||
* - advisory → output.metadata + console.error (best-effort surfacing)
|
||||
*
|
||||
* Namespace conversion (/gsd:xxx → /gsd-xxx) reuses scripts/fix-slash-commands.cjs
|
||||
* via require(), keeping the single source of truth.
|
||||
*
|
||||
* ── Two distribution shapes, one adapter (issue #1914) ─────────────────────
|
||||
* This single file serves both distribution paths, distinguished at load time
|
||||
* by REPO_ROOT (path.resolve(__dirname, "../..")):
|
||||
*
|
||||
* • Option 1 — file copy (the supported GSD path). `bin/install.js` copies
|
||||
* this file to <opencodeConfigDir>/plugins/gsd-core.js, so REPO_ROOT is the
|
||||
* OpenCode config dir. GSD's own install already stages `hooks/*.js` and
|
||||
* `gsd-core/` there (ADR-857 skips hook *registration* for OpenCode, not the
|
||||
* file copy), so the hook bridge and content rewriting resolve natively.
|
||||
* Commands/agents/skills are ALREADY registered by GSD's native file copy in
|
||||
* this mode, so the plugin's own config-hook registration is redundant and is
|
||||
* SKIPPED (see IS_PACKAGE_TREE) to avoid double-registration.
|
||||
*
|
||||
* • Option 2 — package / git-spec. When loaded from the package tree (npm
|
||||
* `main`, or an OpenCode git-spec install), REPO_ROOT is the package root and
|
||||
* the source layout (commands/gsd/, agents/, skills/) is present. Here the
|
||||
* plugin IS the sole registrar, so it registers commands/agents/skills too.
|
||||
*
|
||||
* IS_PACKAGE_TREE keys off the presence of the SOURCE command layout
|
||||
* (commands/gsd/), which only exists in the package tree — never in an installed
|
||||
* config dir (that uses the flattened command/ layout). The hook bridge and
|
||||
* Read-time content rewriting run in BOTH modes; only the config-hook
|
||||
* registration of commands/agents/skills is gated.
|
||||
*
|
||||
* Runtime-specific hooks are deliberately excluded:
|
||||
* - gsd-statusline.js / gsd-update-banner.js (Claude Code statusline)
|
||||
* - gsd-cursor-*.js (Cursor-specific)
|
||||
* - *.sh scripts (invoked directly by commands/agents, not hook events)
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const { spawnSync } = require("child_process");
|
||||
|
||||
// Resolve REPO_ROOT to the directory that actually holds the GSD payload
|
||||
// (hooks/ + gsd-core/). This must work across three physical layouts because a
|
||||
// single adapter file serves both distribution shapes (see header):
|
||||
// • package/git-spec tree: <root>/.opencode/plugins/gsd-core.js → <root>
|
||||
// • global file-copy: ~/.config/opencode/plugins/gsd-core.js → ~/.config/opencode
|
||||
// • local file-copy: <proj>/.opencode/plugins/gsd-core.js → <proj>/.opencode
|
||||
// A fixed "../.." only works for the first; the copied layouts sit one level
|
||||
// shallower. Walking up to the first ancestor containing BOTH payload markers
|
||||
// resolves all three deterministically. Falls back to the package-tree
|
||||
// assumption ("../..") if no ancestor matches (keeps graceful degradation).
|
||||
function resolveRepoRoot(startDir) {
|
||||
let dir = startDir;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
if (
|
||||
fs.existsSync(path.join(dir, "hooks")) &&
|
||||
fs.existsSync(path.join(dir, "gsd-core"))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break; // filesystem root
|
||||
dir = parent;
|
||||
}
|
||||
// No ancestor carried both markers (broken/partial layout — the plugin can't
|
||||
// function regardless). Fall back to the package-tree assumption ("../.."),
|
||||
// matching the historical fixed-depth behavior and the .opencode/plugins/
|
||||
// source layout.
|
||||
return path.resolve(startDir, "../..");
|
||||
}
|
||||
|
||||
// CJS: __dirname is a global, no need to derive from import.meta.url
|
||||
const REPO_ROOT = resolveRepoRoot(__dirname);
|
||||
const HOOKS_DIR = path.join(REPO_ROOT, "hooks");
|
||||
const COMMANDS = path.join(REPO_ROOT, "commands", "gsd");
|
||||
const AGENTS = path.join(REPO_ROOT, "agents");
|
||||
const SKILLS = path.join(REPO_ROOT, "skills");
|
||||
const GSD_CORE = path.join(REPO_ROOT, "gsd-core");
|
||||
|
||||
// True only when loaded from the package/source tree (Option 2), detected by the
|
||||
// presence of the SOURCE command layout (commands/gsd/). In an installed OpenCode
|
||||
// config dir (Option 1) this directory is absent — the flattened command/ layout
|
||||
// is used instead — so the plugin skips its own command/agent/skill registration
|
||||
// and lets GSD's native file copy own that surface (avoids double-registration).
|
||||
const IS_PACKAGE_TREE = fs.existsSync(COMMANDS);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Namespace conversion — reuse the single source of truth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _cmdNames = null;
|
||||
let _transformFn = null;
|
||||
|
||||
/**
|
||||
* Lazily load scripts/fix-slash-commands.cjs and cache the transform function
|
||||
* + command name list. Returns null if the module is unavailable (the plugin
|
||||
* still works, just without namespace conversion).
|
||||
*/
|
||||
function getNamespaceConverter() {
|
||||
if (_transformFn) return _transformFn;
|
||||
try {
|
||||
const mod = require(
|
||||
path.join(REPO_ROOT, "scripts", "fix-slash-commands.cjs"),
|
||||
);
|
||||
_cmdNames = mod.readCmdNames();
|
||||
_transformFn = mod.transformContentToHyphen;
|
||||
return _transformFn;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session state — tracked across plugin hook invocations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let currentSessionId = null;
|
||||
let currentCwd = process.cwd();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool name / argument mapping (OpenCode ↔ Claude Code)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TOOL_NAME_MAP = {
|
||||
read: "Read",
|
||||
write: "Write",
|
||||
edit: "Edit",
|
||||
apply_patch: "MultiEdit",
|
||||
multi_edit: "MultiEdit",
|
||||
bash: "Bash",
|
||||
webfetch: "WebFetch",
|
||||
web_search: "WebSearch",
|
||||
websearch: "WebSearch",
|
||||
task: "Task",
|
||||
subagent: "Task",
|
||||
};
|
||||
|
||||
function mapToolName(tool) {
|
||||
if (!tool) return "";
|
||||
return TOOL_NAME_MAP[String(tool).toLowerCase()] || tool;
|
||||
}
|
||||
|
||||
// Build a Claude-style `tool_input` object from OpenCode's `output.args`.
|
||||
function mapToolInput(args) {
|
||||
const input = {};
|
||||
if (!args || typeof args !== "object") return input;
|
||||
|
||||
// File-path keys (OpenCode uses filePath/path; Claude uses file_path)
|
||||
const filePath = args.filePath || args.path || args.file_path;
|
||||
if (filePath) input.file_path = filePath;
|
||||
|
||||
// Content for Write
|
||||
if (args.content !== undefined) input.content = args.content;
|
||||
|
||||
// Edit patch fields
|
||||
if (args.new_string !== undefined) input.new_string = args.new_string;
|
||||
if (args.newString !== undefined) input.new_string = args.newString;
|
||||
if (args.old_string !== undefined) input.old_string = args.old_string;
|
||||
if (args.oldString !== undefined) input.old_string = args.oldString;
|
||||
|
||||
// Bash command
|
||||
if (args.command !== undefined) input.command = args.command;
|
||||
|
||||
// Web
|
||||
if (args.url !== undefined) input.url = args.url;
|
||||
if (args.query !== undefined) input.query = args.query;
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook subprocess runner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Spawn a Claude Code hook script and pipe a JSON payload to its stdin.
|
||||
*
|
||||
* Hooks follow the convention:
|
||||
* - stdout: JSON object (decision/advisory) or empty
|
||||
* - exit 0: allow (with optional advisory JSON on stdout)
|
||||
* - exit 2: block (Claude convention; reason in stdout JSON)
|
||||
* - any error: exit 0 silently (hooks swallow their own errors)
|
||||
*
|
||||
* @param {string} hookFile filename under hooks/, e.g. "gsd-prompt-guard.js"
|
||||
* @param {object} payload stdin JSON (hook_event_name, tool_name, ...)
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.timeout=8000] spawn timeout in ms
|
||||
* @param {string} [opts.cwd] working directory for the child
|
||||
* @returns {{ stdout: string, exitCode: number, timedOut: boolean }}
|
||||
*/
|
||||
function runHook(hookFile, payload, opts = {}) {
|
||||
const hookPath = path.join(HOOKS_DIR, hookFile);
|
||||
if (!fs.existsSync(hookPath)) {
|
||||
return { stdout: "", exitCode: 0, timedOut: false };
|
||||
}
|
||||
const timeout = opts.timeout ?? 8000;
|
||||
let result;
|
||||
try {
|
||||
result = spawnSync(process.execPath, [hookPath], {
|
||||
input: JSON.stringify(payload),
|
||||
encoding: "utf8",
|
||||
timeout,
|
||||
cwd: opts.cwd || currentCwd,
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {
|
||||
// Spawn failure — never break the tool call
|
||||
return { stdout: "", exitCode: 0, timedOut: false };
|
||||
}
|
||||
|
||||
const stdout = (result.stdout || "").trim();
|
||||
const exitCode = result.status == null ? 0 : result.status;
|
||||
return { stdout, exitCode, timedOut: result.signal === "SIGTERM" };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook output translation → OpenCode semantics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a hook's stdout and apply its effect to the OpenCode output object.
|
||||
*
|
||||
* - Block → throw Error(parsed.reason) so OpenCode aborts the tool call
|
||||
* - Advisory→ append to output.metadata._gsdAdvisory[] and log to stderr
|
||||
* - Silent → no-op
|
||||
*
|
||||
* @param {{ stdout: string, exitCode: number }} hookResult
|
||||
* @param {object} [output] OpenCode mutable output object (optional)
|
||||
*/
|
||||
function handleHookResult(hookResult, output) {
|
||||
const { stdout, exitCode } = hookResult;
|
||||
if (!stdout && exitCode !== 2) return; // silent allow
|
||||
|
||||
let parsed = null;
|
||||
if (stdout) {
|
||||
try {
|
||||
parsed = JSON.parse(stdout);
|
||||
} catch {
|
||||
// Non-JSON stdout (e.g. a stray log) — treat exit 2 as hard block, else allow
|
||||
}
|
||||
}
|
||||
|
||||
// Block: explicit decision OR Claude exit-code-2 convention
|
||||
const isBlock = exitCode === 2 || (parsed && parsed.decision === "block");
|
||||
if (isBlock) {
|
||||
const reason =
|
||||
(parsed && parsed.reason) || "Blocked by GSD hook (no reason provided).";
|
||||
throw new Error(reason);
|
||||
}
|
||||
|
||||
// Advisory: inject additionalContext into metadata + log
|
||||
const advisory =
|
||||
parsed &&
|
||||
parsed.hookSpecificOutput &&
|
||||
parsed.hookSpecificOutput.additionalContext;
|
||||
if (advisory) {
|
||||
if (output) {
|
||||
output.metadata = output.metadata || {};
|
||||
// Accumulate: a single tool call can run several advisory hooks in
|
||||
// sequence (prompt guard, read guard, worktree guard, workflow guard).
|
||||
// Storing a scalar would let a later advisory clobber an earlier one, so
|
||||
// collect them all.
|
||||
if (!Array.isArray(output.metadata._gsdAdvisory)) {
|
||||
output.metadata._gsdAdvisory = [];
|
||||
}
|
||||
output.metadata._gsdAdvisory.push(advisory);
|
||||
}
|
||||
// Best-effort visibility when metadata isn't surfaced to the model
|
||||
console.error(advisory);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frontmatter helpers (for config registration)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseFrontmatter(content) {
|
||||
const m = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
||||
if (!m) return { frontmatter: {}, body: content };
|
||||
const fm = {};
|
||||
for (const line of m[1].split("\n")) {
|
||||
const i = line.indexOf(":");
|
||||
if (i > 0) {
|
||||
let v = line.slice(i + 1).trim();
|
||||
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
|
||||
fm[line.slice(0, i).trim()] = v;
|
||||
}
|
||||
}
|
||||
return { frontmatter: fm, body: m[2] };
|
||||
}
|
||||
|
||||
// Rewrite @~/.claude/ includes to point at the repo root.
|
||||
// Also applies /gsd:xxx → /gsd-xxx namespace conversion via the shared
|
||||
// transform from scripts/fix-slash-commands.cjs (single source of truth).
|
||||
function rewriteRefs(content) {
|
||||
let out = content.replace(/@~\/\.claude\//g, `@${REPO_ROOT}/`);
|
||||
const transform = getNamespaceConverter();
|
||||
if (transform && _cmdNames && _cmdNames.length) {
|
||||
out = transform(out, _cmdNames);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function loadDir(dir, keyFn, valFn) {
|
||||
const result = {};
|
||||
if (!fs.existsSync(dir)) return result;
|
||||
for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".md"))) {
|
||||
const raw = fs.readFileSync(path.join(dir, f), "utf8");
|
||||
const { frontmatter, body } = parseFrontmatter(raw);
|
||||
result[keyFn(f)] = valFn(body, frontmatter, f);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime content transform — for Read tool results on GSD-managed files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Directories whose .md files may contain ~/.claude/ paths and gsd: namespace
|
||||
// refs. When the model reads these via the Read tool, we transparently rewrite
|
||||
// both so OpenCode sees correct paths and hyphen-form command names.
|
||||
const GSD_MANAGED_DIRS = [
|
||||
path.join(GSD_CORE, "workflows"),
|
||||
path.join(GSD_CORE, "references"),
|
||||
path.join(GSD_CORE, "templates"),
|
||||
path.join(GSD_CORE, "contexts"),
|
||||
COMMANDS,
|
||||
AGENTS,
|
||||
SKILLS,
|
||||
];
|
||||
|
||||
function isGsdManagedFile(filePath) {
|
||||
if (!filePath) return false;
|
||||
const resolved = path.resolve(filePath);
|
||||
return GSD_MANAGED_DIRS.some(
|
||||
(dir) => resolved === dir || resolved.startsWith(dir + path.sep),
|
||||
);
|
||||
}
|
||||
|
||||
// Rewrite content for OpenCode consumption:
|
||||
// 1. @-include paths: @~/.claude/ → @<REPO_ROOT>/
|
||||
// 2. plain-text paths: ~/.claude/gsd-core/ → <GSD_CORE>/
|
||||
// 3. namespace: gsd:xxx → gsd-xxx (via fix-slash-commands.cjs)
|
||||
function rewriteContent(content) {
|
||||
let out = content;
|
||||
out = out.replace(/@~\/\.claude\//g, `@${REPO_ROOT}/`);
|
||||
out = out.replace(/~\/\.claude\/gsd-core\//g, `${GSD_CORE}/`);
|
||||
const transform = getNamespaceConverter();
|
||||
if (transform && _cmdNames && _cmdNames.length) {
|
||||
out = transform(out, _cmdNames);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skills cache — copy SKILL.md files with rewritten @-include paths
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// OpenCode's skill loader reads SKILL.md files directly from disk and resolves
|
||||
// @-includes internally — this bypasses our tool.execute hooks. To make
|
||||
// @~/.claude/gsd-core/... includes resolve, we copy all SKILL.md files to a
|
||||
// cache directory with paths rewritten to the actual GSD_CORE location.
|
||||
//
|
||||
// Only used in package-tree mode (Option 2). In an installed OpenCode config
|
||||
// dir (Option 1) skills are already staged + registered by GSD's native file
|
||||
// copy, so we never register skills from the plugin (see IS_PACKAGE_TREE).
|
||||
|
||||
const SKILLS_CACHE = path.join(
|
||||
os.homedir(),
|
||||
".cache",
|
||||
"opencode",
|
||||
"gsd-skills",
|
||||
);
|
||||
|
||||
function prepareSkillsCache() {
|
||||
if (!fs.existsSync(SKILLS)) return null;
|
||||
fs.mkdirSync(SKILLS_CACHE, { recursive: true });
|
||||
for (const dir of fs.readdirSync(SKILLS)) {
|
||||
const srcFile = path.join(SKILLS, dir, "SKILL.md");
|
||||
if (!fs.existsSync(srcFile)) continue;
|
||||
const raw = fs.readFileSync(srcFile, "utf8");
|
||||
// Rewrite @-include paths only; namespace conversion is handled at
|
||||
// Read-time via tool.execute.after for workflow/reference files.
|
||||
const rewritten = raw
|
||||
.replace(/@~\/\.claude\/gsd-core\//g, `@${GSD_CORE}/`)
|
||||
.replace(/~\/\.claude\/gsd-core\//g, `${GSD_CORE}/`);
|
||||
const destDir = path.join(SKILLS_CACHE, dir);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(destDir, "SKILL.md"), rewritten);
|
||||
}
|
||||
return SKILLS_CACHE;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Plugin entry
|
||||
// ===========================================================================
|
||||
|
||||
const GsdCorePlugin = async ({ directory } = {}) => {
|
||||
if (directory) currentCwd = directory;
|
||||
|
||||
return {
|
||||
// ── Config: register commands / agents / skills paths ──────────────
|
||||
// Only in package-tree mode (Option 2). In an installed config dir
|
||||
// (Option 1) GSD's native file copy already registered these, so the
|
||||
// plugin stays out of registration to avoid double-registering.
|
||||
config: async (config) => {
|
||||
if (!IS_PACKAGE_TREE) return;
|
||||
|
||||
// Commands (commands/gsd/*.md → gsd-<name>)
|
||||
config.command = config.command || {};
|
||||
const cmds = loadDir(
|
||||
COMMANDS,
|
||||
(f) => "gsd-" + f.slice(0, -3),
|
||||
(body, fm, name) => ({
|
||||
template: rewriteRefs(body.trim()),
|
||||
description: fm.description || `GSD ${name.slice(0, -3)} command`,
|
||||
}),
|
||||
);
|
||||
for (const [k, v] of Object.entries(cmds)) {
|
||||
if (!config.command[k]) config.command[k] = v;
|
||||
}
|
||||
|
||||
// Agents (agents/*.md)
|
||||
config.agent = config.agent || {};
|
||||
const agents = loadDir(
|
||||
AGENTS,
|
||||
(f) => f.slice(0, -3),
|
||||
(body, fm, name) => ({
|
||||
prompt: rewriteRefs(body.trim()),
|
||||
description: fm.description || `GSD ${name.slice(0, -3)} agent`,
|
||||
mode: fm.mode || "subagent",
|
||||
}),
|
||||
);
|
||||
for (const [k, v] of Object.entries(agents)) {
|
||||
if (!config.agent[k]) config.agent[k] = v;
|
||||
}
|
||||
|
||||
// Skills — copy SKILL.md files to cache with rewritten @-include paths,
|
||||
// then register the cache directory. OpenCode's skill loader reads
|
||||
// SKILL.md from disk and resolves @-includes internally (bypassing our
|
||||
// tool.execute hooks), so we must pre-process the files.
|
||||
const skillsCache = prepareSkillsCache();
|
||||
config.skills = config.skills || {};
|
||||
config.skills.paths = config.skills.paths || [];
|
||||
const skillsPath = skillsCache || SKILLS;
|
||||
if (!config.skills.paths.includes(skillsPath)) {
|
||||
config.skills.paths.push(skillsPath);
|
||||
}
|
||||
},
|
||||
|
||||
// ── shell.env ───────────────────────────────────────────────────────
|
||||
"shell.env": async (_input, output) => {
|
||||
output.env = output.env || {};
|
||||
output.env.GSD_DIR = GSD_CORE;
|
||||
},
|
||||
|
||||
// ── tool.execute.before — PreToolUse hooks ─────────────────────────
|
||||
"tool.execute.before": async (input, output) => {
|
||||
const claudeTool = mapToolName(input.tool);
|
||||
const toolInput = mapToolInput(output.args || {});
|
||||
const cwd = currentCwd;
|
||||
|
||||
// 0. Read path rewrite — redirect ~/.claude/gsd-core/ to actual GSD_CORE
|
||||
// so the model can read workflow/reference/template files that SKILL.md
|
||||
// and command templates reference via the canonical Claude path.
|
||||
if (claudeTool === "Read" && toolInput.file_path) {
|
||||
const original = toolInput.file_path;
|
||||
const rewritten = original
|
||||
.replace(/^~\/\.claude\/gsd-core\//, GSD_CORE + "/")
|
||||
.replace(/(?:.*)\/\.claude\/gsd-core\//, GSD_CORE + "/");
|
||||
if (rewritten !== original) {
|
||||
const args = output.args || {};
|
||||
if (args.filePath) args.filePath = rewritten;
|
||||
else if (args.path) args.path = rewritten;
|
||||
else if (args.file_path) args.file_path = rewritten;
|
||||
else args.filePath = rewritten;
|
||||
}
|
||||
}
|
||||
|
||||
const basePayload = {
|
||||
hook_event_name: "PreToolUse",
|
||||
cwd,
|
||||
};
|
||||
// NOTE: session_id intentionally omitted for PreToolUse hooks.
|
||||
// gsd-read-guard.js treats a non-empty session_id as a Claude Code
|
||||
// session and skips its advisory. On OpenCode we WANT the advisory.
|
||||
const prePayload = (overrides = {}) => ({
|
||||
...basePayload,
|
||||
tool_name: claudeTool,
|
||||
tool_input: toolInput,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const isWriteLike = ["Write", "Edit", "MultiEdit"].includes(claudeTool);
|
||||
|
||||
// 1. gsd-prompt-guard.js — injection scan on .planning/ writes
|
||||
if (claudeTool === "Write" || claudeTool === "Edit") {
|
||||
const r = runHook("gsd-prompt-guard.js", prePayload());
|
||||
handleHookResult(r, output);
|
||||
}
|
||||
|
||||
// 2. gsd-read-guard.js — read-before-edit advisory
|
||||
if (claudeTool === "Write" || claudeTool === "Edit") {
|
||||
const r = runHook("gsd-read-guard.js", prePayload());
|
||||
handleHookResult(r, output);
|
||||
}
|
||||
|
||||
// 3. gsd-worktree-path-guard.js — hard-block edits outside worktree
|
||||
if (isWriteLike) {
|
||||
const r = runHook("gsd-worktree-path-guard.js", prePayload());
|
||||
handleHookResult(r, output);
|
||||
}
|
||||
|
||||
// 4. gsd-workflow-guard.js — workflow advisory + git-force-add block
|
||||
// (covers Write/Edit/MultiEdit AND Bash force-add detection)
|
||||
if (isWriteLike || claudeTool === "Bash") {
|
||||
const r = runHook("gsd-workflow-guard.js", prePayload());
|
||||
handleHookResult(r, output);
|
||||
}
|
||||
},
|
||||
|
||||
// ── tool.execute.after — PostToolUse hooks ─────────────────────────
|
||||
"tool.execute.after": async (input, output) => {
|
||||
const claudeTool = mapToolName(input.tool);
|
||||
// NOTE: In the `after` hook, `args` lives on `input` (not `output`).
|
||||
// The `output` object only has { title, output, metadata }.
|
||||
const toolInput = mapToolInput(input.args || {});
|
||||
const cwd = currentCwd;
|
||||
|
||||
// GSD content transform — rewrite paths + namespace in Read results
|
||||
// BEFORE injection scanning so the scanner sees the final content.
|
||||
if (
|
||||
claudeTool === "Read" &&
|
||||
output.output &&
|
||||
isGsdManagedFile(toolInput.file_path)
|
||||
) {
|
||||
const content =
|
||||
typeof output.output === "string"
|
||||
? output.output
|
||||
: String(output.output);
|
||||
output.output = rewriteContent(content);
|
||||
}
|
||||
|
||||
// gsd-read-injection-scanner.js — scan Read/WebFetch/WebSearch results
|
||||
if (
|
||||
claudeTool === "Read" ||
|
||||
claudeTool === "WebFetch" ||
|
||||
claudeTool === "WebSearch"
|
||||
) {
|
||||
const payload = {
|
||||
hook_event_name: "PostToolUse",
|
||||
tool_name: claudeTool,
|
||||
tool_input: toolInput,
|
||||
tool_response: output.output,
|
||||
cwd,
|
||||
};
|
||||
const r = runHook("gsd-read-injection-scanner.js", payload);
|
||||
handleHookResult(r, output);
|
||||
return;
|
||||
}
|
||||
|
||||
// gsd-context-monitor.js — context usage warnings (Bash/Edit/Write/Task/...)
|
||||
// Only meaningful when a session_id is tracked (writes metrics sentinel).
|
||||
if (currentSessionId) {
|
||||
const payload = {
|
||||
hook_event_name: "PostToolUse",
|
||||
tool_name: claudeTool,
|
||||
tool_input: toolInput,
|
||||
session_id: currentSessionId,
|
||||
cwd,
|
||||
};
|
||||
const r = runHook("gsd-context-monitor.js", payload);
|
||||
handleHookResult(r, output);
|
||||
}
|
||||
},
|
||||
|
||||
// ── experimental.session.compacting — PreCompact ───────────────────
|
||||
"experimental.session.compacting": async (_input, output) => {
|
||||
if (!currentSessionId) return;
|
||||
const payload = {
|
||||
hook_event_name: "PreCompact",
|
||||
session_id: currentSessionId,
|
||||
cwd: currentCwd,
|
||||
};
|
||||
const r = runHook("gsd-context-monitor.js", payload);
|
||||
handleHookResult(r, output);
|
||||
|
||||
// Also inject a GSD compaction breadcrumb (mirrors the original plugin)
|
||||
output.context = output.context || [];
|
||||
output.context.push(
|
||||
`[GSD] Active session: ${currentSessionId}. Preserve any in-flight phase/plan state.`,
|
||||
);
|
||||
},
|
||||
|
||||
// ── General event subscriptions ─────────────────────────────────────
|
||||
event: async ({ event }) => {
|
||||
// session.created → SessionStart hooks
|
||||
if (event.type === "session.created") {
|
||||
// Track session for context-monitor payloads.
|
||||
// SDK type EventSessionCreated: { properties: { info: Session } }
|
||||
// Session has `id` and `directory` (not `cwd`).
|
||||
const info = event.properties?.info;
|
||||
currentSessionId =
|
||||
info?.id || event.sessionID || event.session_id || null;
|
||||
if (info?.directory) currentCwd = info.directory;
|
||||
|
||||
// gsd-ensure-canonical-path.js — no stdin dependency; silent
|
||||
runHook("gsd-ensure-canonical-path.js", {
|
||||
hook_event_name: "SessionStart",
|
||||
session_id: currentSessionId,
|
||||
cwd: currentCwd,
|
||||
});
|
||||
// gsd-check-update.js — spawns its own background worker; no stdin
|
||||
runHook("gsd-check-update.js", {
|
||||
hook_event_name: "SessionStart",
|
||||
session_id: currentSessionId,
|
||||
cwd: currentCwd,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// file.edited → FileChanged hook (config.json reload)
|
||||
if (event.type === "file.edited") {
|
||||
// SDK type EventFileEdited: { properties: { file: string } }
|
||||
const filePath = event.properties?.file || event.filePath || "";
|
||||
if (!filePath.endsWith("config.json")) return;
|
||||
const cwd = event.properties?.cwd || currentCwd;
|
||||
const expected = path.join(cwd, ".planning", "config.json");
|
||||
if (path.resolve(filePath) !== path.resolve(expected)) return;
|
||||
|
||||
const payload = {
|
||||
hook_event_name: "FileChanged",
|
||||
file_path: filePath,
|
||||
event: "change",
|
||||
cwd,
|
||||
};
|
||||
const r = runHook("gsd-config-reload.js", payload);
|
||||
// Advisory-only (additionalContext); surface to logs
|
||||
handleHookResult(r);
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Export shape — verified against OpenCode's plugin loader source
|
||||
// (packages/opencode/src/plugin). The loader imports this module and runs
|
||||
// `for (const entry of Object.values(mod)) { getServerPlugin(entry) }`, where
|
||||
// `getServerPlugin` accepts a bare function OR an object exposing a `.server`
|
||||
// function, and THROWS `TypeError("Plugin export is not a function")` for
|
||||
// anything else. So EVERY enumerable value the loader iterates must be a
|
||||
// function or an object with `.server`.
|
||||
//
|
||||
// The subtlety: depending on how OpenCode's runtime (Node or Bun) imports a
|
||||
// CommonJS file, `mod` may be the raw `module.exports` OR an ESM namespace of
|
||||
// the form `{ default: module.exports, ...syntheticNamedExports }`. A plain
|
||||
// `module.exports = { id: "gsd-core", server }` literal risks a string `id`
|
||||
// appearing in `Object.values(mod)` (as a raw property, or as a lexer-
|
||||
// synthesized named export) — which would trip the throw. Two defenses:
|
||||
// 1. `id` is defined NON-ENUMERABLE, so it never appears in Object.values yet
|
||||
// stays readable (via property access) for the loader's identity/dedup.
|
||||
// 2. `module.exports` is assigned from a VARIABLE (not an object literal), so
|
||||
// cjs-module-lexer cannot statically synthesize named exports from it —
|
||||
// only `default` is exposed under ESM/Bun interop.
|
||||
// Result: raw-CJS `Object.values` = `[server]`; ESM `Object.values` =
|
||||
// `[{server, <id non-enum>}]` — both fully extractable. Test-only helpers hang
|
||||
// off the `server` FUNCTION (`server._internals`), never as a sibling export.
|
||||
GsdCorePlugin._internals = {
|
||||
REPO_ROOT,
|
||||
IS_PACKAGE_TREE,
|
||||
mapToolName,
|
||||
mapToolInput,
|
||||
parseFrontmatter,
|
||||
rewriteContent,
|
||||
isGsdManagedFile,
|
||||
handleHookResult,
|
||||
GsdCorePlugin,
|
||||
};
|
||||
|
||||
const gsdCorePluginExport = { server: GsdCorePlugin };
|
||||
Object.defineProperty(gsdCorePluginExport, "id", {
|
||||
value: "gsd-core",
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
});
|
||||
module.exports = gsdCorePluginExport;
|
||||
@@ -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.
|
||||
|
||||
@@ -170,7 +170,9 @@ The extension loads GSD's operating context (`GEMINI.md`) into every session and
|
||||
npx @opengsd/gsd-core@latest --opencode --global
|
||||
```
|
||||
|
||||
The installer writes three surfaces under `~/.config/opencode/` (XDG) or `~/.opencode/`: flat slash commands in `command/`, file-based subagents in `agents/`, and on-demand skills in `skills/<name>/SKILL.md`. It converts agent frontmatter to OpenCode's schema — removing the `tools:` field and converting colour values to hex — and emits each skill with spec-compliant frontmatter (`name` matching the skill directory plus a `description`). Skills are loaded on demand via OpenCode's native skill tool; commands remain invokable as `/gsd-*`. See [Installing without Node.js — OpenCode transformations](#opencode--required-transformations) if you need to understand what changes.
|
||||
The installer writes four surfaces under `~/.config/opencode/` (XDG) or `~/.opencode/`: flat slash commands in `command/`, file-based subagents in `agents/`, on-demand skills in `skills/<name>/SKILL.md`, and a native plugin in `plugins/gsd-core.js`. It converts agent frontmatter to OpenCode's schema — removing the `tools:` field and converting colour values to hex — and emits each skill with spec-compliant frontmatter (`name` matching the skill directory plus a `description`). Skills are loaded on demand via OpenCode's native skill tool; commands remain invokable as `/gsd-*`. See [Installing without Node.js — OpenCode transformations](#opencode--required-transformations) if you need to understand what changes.
|
||||
|
||||
**GSD safety hooks on OpenCode.** OpenCode does not register lifecycle hooks the way Claude Code does (its `hooksSurface` is `none`), so GSD's prompt-injection guard, read-before-edit guard, injection scanner, and context monitor would otherwise be inert. The bundled plugin (`plugins/gsd-core.js`) closes that gap: OpenCode auto-discovers `plugins/*.{ts,js}` files under its config directory at startup and the adapter bridges OpenCode's event bus (`tool.execute.before`/`after`, `session.created`, `file.edited`) onto GSD's existing hook scripts, spawning them as subprocesses. No `opencode.json` entry is needed — the plugin is loaded by directory auto-discovery (the config `plugin` array is for npm packages only). A blocking hook aborts the tool call; an advisory hook surfaces its message without blocking.
|
||||
|
||||
**Override the install directory:**
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "@opengsd/gsd-core",
|
||||
"version": "1.7.0-rc.1",
|
||||
"description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
|
||||
"main": ".opencode/plugins/gsd-core.js",
|
||||
"bin": {
|
||||
"gsd-core": "bin/install.js",
|
||||
"gsd-tools": "gsd-core/bin/gsd-tools.cjs",
|
||||
@@ -16,6 +17,7 @@
|
||||
"assets",
|
||||
"agents",
|
||||
".claude-plugin",
|
||||
".opencode",
|
||||
"gemini-extension.json",
|
||||
"GEMINI.md",
|
||||
"hooks",
|
||||
|
||||
@@ -392,6 +392,7 @@
|
||||
"hooks/managed-hooks-registry.cjs": "763730ef31e5fd1c",
|
||||
"opencode.json": "13151e97ff23c1aa",
|
||||
"package.json": "dbf8353f77358bc1",
|
||||
"plugins/gsd-core.js": "8ae69107bf3036a0",
|
||||
"scripts/changeset/README.md": "86ff89331dfd94b2",
|
||||
"scripts/changeset/cli.cjs": "68f92a344b199271",
|
||||
"scripts/changeset/github-release-notes.cjs": "795677f0c009b132",
|
||||
|
||||
342
tests/opencode-plugin-adapter.test.cjs
Normal file
342
tests/opencode-plugin-adapter.test.cjs
Normal file
@@ -0,0 +1,342 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* opencode-plugin-adapter.test.cjs — unit + integration coverage for the
|
||||
* OpenCode native plugin adapter (.opencode/plugins/gsd-core.js, issue #1914).
|
||||
*
|
||||
* The adapter bridges OpenCode's plugin event bus onto GSD's existing hook
|
||||
* scripts by spawning them as subprocesses. These tests exercise it WITHOUT a
|
||||
* live OpenCode runtime by:
|
||||
* 1. Unit-testing the pure translation helpers exposed on `_internals`.
|
||||
* 2. Building a temp "install" layout (hooks/ with deterministic STUB hooks +
|
||||
* gsd-core/ + plugins/gsd-core.js) and driving the plugin's returned
|
||||
* handlers directly, asserting the real spawn bridge maps block/advisory/
|
||||
* allow correctly and that REPO_ROOT resolves to the payload dir.
|
||||
*
|
||||
* Cross-platform note: filesystem-failure paths are not exercised here; the
|
||||
* adapter's own error handling swallows spawn failures by design (a broken hook
|
||||
* must never break a tool call), which the "missing hook" case covers.
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('os');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const ADAPTER_SRC = path.join(__dirname, '..', '.opencode', 'plugins', 'gsd-core.js');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure-helper unit tests (no filesystem / no spawn)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Test-only helpers hang off the exported `server` function (see adapter export
|
||||
// note) so they never appear as top-level exports the OpenCode loader iterates.
|
||||
const _internals = require(ADAPTER_SRC).server._internals;
|
||||
|
||||
// Faithful emulation of OpenCode's loader: `getServerPlugin` accepts a bare
|
||||
// function OR an object with a `.server` function, else the loader THROWS.
|
||||
function getServerPlugin(entry) {
|
||||
if (typeof entry === 'function') return entry;
|
||||
if (entry && typeof entry === 'object' && typeof entry.server === 'function') return entry.server;
|
||||
return null;
|
||||
}
|
||||
// Emulate the loader loop: `for (const entry of Object.values(mod)) { … throw if null }`.
|
||||
function loaderExtract(mod) {
|
||||
const servers = [];
|
||||
for (const entry of Object.values(mod)) {
|
||||
const s = getServerPlugin(entry);
|
||||
if (!s) throw new TypeError('Plugin export is not a function');
|
||||
servers.push(s);
|
||||
}
|
||||
return servers;
|
||||
}
|
||||
|
||||
test('export survives the loader loop as raw CommonJS (require)', () => {
|
||||
const mod = require(ADAPTER_SRC);
|
||||
// `id` must be readable for identity/dedup...
|
||||
assert.equal(mod.id, 'gsd-core');
|
||||
// ...but NON-ENUMERABLE so it never lands in Object.values (would throw).
|
||||
assert.ok(!Object.keys(mod).includes('id'), 'id must be non-enumerable');
|
||||
const servers = loaderExtract(mod); // must not throw
|
||||
assert.equal(servers.length, 1);
|
||||
assert.equal(typeof servers[0], 'function');
|
||||
// Internals hang off the server fn, never as a sibling top-level export.
|
||||
assert.equal(mod._internals, undefined);
|
||||
assert.equal(typeof mod.server._internals, 'object');
|
||||
});
|
||||
|
||||
test('export survives the loader loop as an ESM/Bun namespace (default + synthesized)', () => {
|
||||
const raw = require(ADAPTER_SRC);
|
||||
// Worst-case ESM interop: default plus any lexer-synthesized named exports.
|
||||
// Because module.exports is assigned from a variable, only `default` is
|
||||
// realistically synthesized — but assert robustness even if `server` leaks.
|
||||
for (const ns of [{ default: raw }, { default: raw, server: raw.server }]) {
|
||||
assert.doesNotThrow(() => loaderExtract(ns), `loader threw on namespace ${Object.keys(ns)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('mapToolName maps OpenCode tool names to Claude names', () => {
|
||||
assert.equal(_internals.mapToolName('read'), 'Read');
|
||||
assert.equal(_internals.mapToolName('write'), 'Write');
|
||||
assert.equal(_internals.mapToolName('edit'), 'Edit');
|
||||
assert.equal(_internals.mapToolName('bash'), 'Bash');
|
||||
assert.equal(_internals.mapToolName('apply_patch'), 'MultiEdit');
|
||||
assert.equal(_internals.mapToolName('webfetch'), 'WebFetch');
|
||||
// Unknown tools pass through unchanged; empty is empty.
|
||||
assert.equal(_internals.mapToolName('mystery'), 'mystery');
|
||||
assert.equal(_internals.mapToolName(''), '');
|
||||
});
|
||||
|
||||
test('mapToolInput normalizes camelCase + snake_case arg keys', () => {
|
||||
const out = _internals.mapToolInput({
|
||||
filePath: '/a/b.txt',
|
||||
oldString: 'x',
|
||||
newString: 'y',
|
||||
command: 'ls',
|
||||
url: 'http://e',
|
||||
});
|
||||
assert.deepEqual(out, {
|
||||
file_path: '/a/b.txt',
|
||||
old_string: 'x',
|
||||
new_string: 'y',
|
||||
command: 'ls',
|
||||
url: 'http://e',
|
||||
});
|
||||
// path/file_path aliases also resolve to file_path.
|
||||
assert.equal(_internals.mapToolInput({ path: '/p' }).file_path, '/p');
|
||||
assert.deepEqual(_internals.mapToolInput(null), {});
|
||||
});
|
||||
|
||||
test('parseFrontmatter splits frontmatter and body', () => {
|
||||
const { frontmatter, body } = _internals.parseFrontmatter(
|
||||
'---\ndescription: A command\nmode: primary\n---\nHello body\n',
|
||||
);
|
||||
assert.equal(frontmatter.description, 'A command');
|
||||
assert.equal(frontmatter.mode, 'primary');
|
||||
assert.equal(body, 'Hello body\n');
|
||||
// No frontmatter → whole content is body.
|
||||
const plain = _internals.parseFrontmatter('just text');
|
||||
assert.deepEqual(plain.frontmatter, {});
|
||||
assert.equal(plain.body, 'just text');
|
||||
});
|
||||
|
||||
test('handleHookResult: block decision throws with the hook reason', () => {
|
||||
assert.throws(
|
||||
() => _internals.handleHookResult(
|
||||
{ stdout: JSON.stringify({ decision: 'block', reason: 'blocked!' }), exitCode: 0 },
|
||||
),
|
||||
/blocked!/,
|
||||
);
|
||||
});
|
||||
|
||||
test('handleHookResult: exit code 2 is a hard block even without JSON', () => {
|
||||
assert.throws(
|
||||
() => _internals.handleHookResult({ stdout: '', exitCode: 2 }),
|
||||
/Blocked by GSD hook/,
|
||||
);
|
||||
});
|
||||
|
||||
test('handleHookResult: advisory sets metadata + does not throw', () => {
|
||||
const output = {};
|
||||
assert.doesNotThrow(() =>
|
||||
_internals.handleHookResult(
|
||||
{ stdout: JSON.stringify({ hookSpecificOutput: { additionalContext: 'heads up' } }), exitCode: 0 },
|
||||
output,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(output.metadata._gsdAdvisory, ['heads up']);
|
||||
});
|
||||
|
||||
test('handleHookResult: multiple advisories accumulate (no clobber)', () => {
|
||||
// A single tool call runs several advisory hooks in sequence; each must be
|
||||
// preserved, not overwritten by the next.
|
||||
const output = {};
|
||||
const advise = (ctx) =>
|
||||
_internals.handleHookResult(
|
||||
{ stdout: JSON.stringify({ hookSpecificOutput: { additionalContext: ctx } }), exitCode: 0 },
|
||||
output,
|
||||
);
|
||||
advise('prompt-guard note');
|
||||
advise('read-guard note');
|
||||
advise('workflow-guard note');
|
||||
assert.deepEqual(output.metadata._gsdAdvisory, [
|
||||
'prompt-guard note',
|
||||
'read-guard note',
|
||||
'workflow-guard note',
|
||||
]);
|
||||
});
|
||||
|
||||
test('handleHookResult: silent allow is a no-op', () => {
|
||||
const output = {};
|
||||
assert.doesNotThrow(() => _internals.handleHookResult({ stdout: '', exitCode: 0 }, output));
|
||||
assert.deepEqual(output, {});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration: drive the plugin against a temp install layout with STUB hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Build a self-contained payload dir: <root>/hooks/<stub>.js, <root>/gsd-core/,
|
||||
// and <root>/plugins/gsd-core.js (a copy of the adapter). Returns the loaded
|
||||
// plugin module for that layout. Each stub hook echoes a fixed JSON verdict.
|
||||
function buildInstalledLayout(t, stubHooks) {
|
||||
// realpath so `root` matches Node's realpath-resolved __dirname inside the
|
||||
// copied plugin (macOS /var → /private/var symlink would otherwise diverge).
|
||||
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-oc-plugin-')));
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
fs.mkdirSync(path.join(root, 'hooks'), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, 'gsd-core', 'workflows'), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, 'plugins'), { recursive: true });
|
||||
|
||||
for (const [name, jsBody] of Object.entries(stubHooks)) {
|
||||
fs.writeFileSync(path.join(root, 'hooks', name), jsBody);
|
||||
}
|
||||
|
||||
// Copy the real adapter into the payload's plugins/ dir so REPO_ROOT resolves
|
||||
// to `root` via the walk-up probe (root has both hooks/ and gsd-core/).
|
||||
const dest = path.join(root, 'plugins', 'gsd-core.js');
|
||||
fs.copyFileSync(ADAPTER_SRC, dest);
|
||||
// Fresh module instance (bypass require cache — each layout is distinct).
|
||||
delete require.cache[require.resolve(dest)];
|
||||
const mod = require(dest);
|
||||
return { root, mod };
|
||||
}
|
||||
|
||||
// A stub hook that reads stdin (ignored) and prints the given verdict JSON.
|
||||
function stubHook(verdictJson, exitCode = 0) {
|
||||
return `
|
||||
let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{
|
||||
process.stdout.write(${JSON.stringify(verdictJson)});
|
||||
process.exit(${exitCode});
|
||||
});
|
||||
process.stdin.on('error',()=>process.exit(${exitCode}));
|
||||
if(process.stdin.isTTY){process.stdout.write(${JSON.stringify(verdictJson)});process.exit(${exitCode});}
|
||||
`;
|
||||
}
|
||||
|
||||
test('REPO_ROOT resolves to the payload dir in an installed layout', (t) => {
|
||||
const { root, mod } = buildInstalledLayout(t, {});
|
||||
assert.equal(mod.server._internals.REPO_ROOT, fs.realpathSync(root));
|
||||
// No source commands/gsd/ present → treated as installed (not package) tree.
|
||||
assert.equal(mod.server._internals.IS_PACKAGE_TREE, false);
|
||||
});
|
||||
|
||||
test('tool.execute.before: a blocking hook aborts the tool call (throws)', async (t) => {
|
||||
const { mod } = buildInstalledLayout(t, {
|
||||
'gsd-prompt-guard.js': stubHook(JSON.stringify({ decision: 'block', reason: 'injection detected' })),
|
||||
});
|
||||
const handlers = await mod.server({ directory: process.cwd() });
|
||||
await assert.rejects(
|
||||
() => handlers['tool.execute.before'](
|
||||
{ tool: 'write' },
|
||||
{ args: { filePath: '/proj/.planning/x.md', content: 'evil' } },
|
||||
),
|
||||
/injection detected/,
|
||||
);
|
||||
});
|
||||
|
||||
test('tool.execute.before: a silent hook allows the tool call (no throw)', async (t) => {
|
||||
const { mod } = buildInstalledLayout(t, {
|
||||
'gsd-prompt-guard.js': stubHook(''),
|
||||
'gsd-read-guard.js': stubHook(''),
|
||||
'gsd-worktree-path-guard.js': stubHook(''),
|
||||
'gsd-workflow-guard.js': stubHook(''),
|
||||
});
|
||||
const handlers = await mod.server({ directory: process.cwd() });
|
||||
await assert.doesNotReject(() =>
|
||||
handlers['tool.execute.before'](
|
||||
{ tool: 'write' },
|
||||
{ args: { filePath: '/proj/notes.md', content: 'ok' } },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('tool.execute.after: Read content rewriting maps ~/.claude/gsd-core paths', async (t) => {
|
||||
const { root, mod } = buildInstalledLayout(t, {
|
||||
'gsd-read-injection-scanner.js': stubHook(''),
|
||||
});
|
||||
const handlers = await mod.server({ directory: process.cwd() });
|
||||
// A file under the payload's gsd-core/workflows is a GSD-managed file, so its
|
||||
// Read output is rewritten (canonical ~/.claude/gsd-core/ → real payload path).
|
||||
const managed = path.join(root, 'gsd-core', 'workflows', 'x.md');
|
||||
const output = { output: 'see ~/.claude/gsd-core/references/foo.md for details' };
|
||||
await handlers['tool.execute.after']({ tool: 'read', args: { filePath: managed } }, output);
|
||||
// The adapter rewrites `~/.claude/gsd-core/` → `${GSD_CORE}/`, where GSD_CORE
|
||||
// is `path.join(root, 'gsd-core')` (OS-native separators). Assert with a plain
|
||||
// string include, NOT a RegExp built from a path — on Windows the backslashes
|
||||
// in the path would be interpreted as regex escapes and never match.
|
||||
const expected = path.join(root, 'gsd-core') + '/references/foo.md';
|
||||
assert.ok(
|
||||
output.output.includes(expected),
|
||||
`expected rewritten path "${expected}" in output: ${output.output}`,
|
||||
);
|
||||
assert.ok(
|
||||
!output.output.includes('~/.claude/gsd-core/'),
|
||||
'canonical ~/.claude/gsd-core/ prefix must be rewritten away',
|
||||
);
|
||||
});
|
||||
|
||||
test('missing hook script is a silent allow (never breaks the tool call)', async (t) => {
|
||||
// No hook stubs written at all → every runHook finds no file → silent allow.
|
||||
const { mod } = buildInstalledLayout(t, {});
|
||||
const handlers = await mod.server({ directory: process.cwd() });
|
||||
await assert.doesNotReject(() =>
|
||||
handlers['tool.execute.before'](
|
||||
{ tool: 'edit' },
|
||||
{ args: { filePath: '/proj/a.md', old_string: 'a', new_string: 'b' } },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('config hook is a no-op in installed (non-package) layout', async (t) => {
|
||||
const { mod } = buildInstalledLayout(t, {});
|
||||
const handlers = await mod.server({ directory: process.cwd() });
|
||||
const config = {};
|
||||
await handlers.config(config);
|
||||
// No commands/agents/skills registered — native file copy owns that surface.
|
||||
assert.deepEqual(config, {});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Installer integration: copy → manifest → uninstall (real bin/install.js)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('installer copies plugin as .js, records it in the manifest, and removes it on uninstall', (t) => {
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const installer = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const cfg = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-oc-install-')));
|
||||
t.after(() => cleanup(cfg));
|
||||
|
||||
const run = (args) =>
|
||||
spawnSync(process.execPath, [installer, '--opencode', '--global', '--config-dir', cfg, ...args], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
// Install
|
||||
const install = run([]);
|
||||
assert.equal(install.status, 0, `install failed: ${install.stderr}`);
|
||||
|
||||
const pluginPath = path.join(cfg, 'plugins', 'gsd-core.js');
|
||||
assert.ok(fs.existsSync(pluginPath), 'plugin must land at plugins/gsd-core.js (matches OpenCode {plugin,plugins}/*.{ts,js} glob)');
|
||||
assert.ok(!fs.existsSync(path.join(cfg, 'plugins', 'gsd-core.cjs')), 'must NOT ship a .cjs (never auto-discovered)');
|
||||
|
||||
// Manifest records the plugin for drift/uninstall accounting.
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(cfg, 'gsd-file-manifest.json'), 'utf8'));
|
||||
assert.ok(manifest.files['plugins/gsd-core.js'], 'manifest must track plugins/gsd-core.js');
|
||||
|
||||
// The installed plugin loads and resolves REPO_ROOT to the config dir.
|
||||
delete require.cache[require.resolve(pluginPath)];
|
||||
const installed = require(pluginPath);
|
||||
assert.equal(installed.id, 'gsd-core');
|
||||
assert.equal(installed.server._internals.REPO_ROOT, cfg);
|
||||
assert.equal(installed.server._internals.IS_PACKAGE_TREE, false);
|
||||
|
||||
// Uninstall removes the plugin and prunes the (now empty) plugins/ dir.
|
||||
const uninstall = run(['--uninstall']);
|
||||
assert.equal(uninstall.status, 0, `uninstall failed: ${uninstall.stderr}`);
|
||||
assert.ok(!fs.existsSync(pluginPath), 'plugin must be removed on uninstall');
|
||||
assert.ok(!fs.existsSync(path.join(cfg, 'plugins')), 'empty plugins/ dir must be pruned');
|
||||
});
|
||||
Reference in New Issue
Block a user