diff --git a/.changeset/proud-bears-roam.md b/.changeset/proud-bears-roam.md new file mode 100644 index 000000000..69728b6ee --- /dev/null +++ b/.changeset/proud-bears-roam.md @@ -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. diff --git a/.gitignore b/.gitignore index 79a512a4a..300291314 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.opencode/plugins/gsd-core.js b/.opencode/plugins/gsd-core.js new file mode 100644 index 000000000..0f89d3c9f --- /dev/null +++ b/.opencode/plugins/gsd-core.js @@ -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 /.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 /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: /.opencode/plugins/gsd-core.js → +// • global file-copy: ~/.config/opencode/plugins/gsd-core.js → ~/.config/opencode +// • local file-copy: /.opencode/plugins/gsd-core.js → /.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/ → @/ +// 2. plain-text paths: ~/.claude/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-) + 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, }]` — 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; diff --git a/bin/install.js b/bin/install.js index 6ab82cf7b..31c722fad 100755 --- a/bin/install.js +++ b/bin/install.js @@ -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 /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 + // /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. diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index bd11f0756..d97430123 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -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//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//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:** diff --git a/package.json b/package.json index 3aea61b19..e7bfaf216 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index 48a6aa0b9..ff269fa4b 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -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", diff --git a/tests/opencode-plugin-adapter.test.cjs b/tests/opencode-plugin-adapter.test.cjs new file mode 100644 index 000000000..58fb11127 --- /dev/null +++ b/tests/opencode-plugin-adapter.test.cjs @@ -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: /hooks/.js, /gsd-core/, +// and /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'); +});