diff --git a/docs/editor-setup.md b/docs/editor-setup.md
index a0e30f1fb..467856b9a 100644
--- a/docs/editor-setup.md
+++ b/docs/editor-setup.md
@@ -13,13 +13,19 @@
| **Antigravity** (Google) | Yes | Yes | Yes (AfterTool, [Gemini CLI hooks schema](https://geminicli.com/docs/hooks/reference/))[¹](#fn-antigravity-hooks) | **Full** |
| **Codex** | Yes | Yes | Yes (PreToolUse + PostToolUse, [Codex hooks](https://developers.openai.com/codex/hooks)) | **Full** |
| **Factory** (Droid) | Yes | Yes | Yes (PostToolUse, [plugin](../gitnexus-factory-plugin/)) | **Full** |
-| **OpenCode** | Yes | Yes | — | MCP + Skills |
+| **OpenCode** | Yes | Yes | Yes (`tool.execute.after`, [plugin](https://opencode.ai/docs/plugins/)) | **Full** |
| **CodeBuddy** (Tencent) | Yes | Yes | — | MCP + Skills |
| **Qoder** (Alibaba) | Yes | Yes | — | MCP + Skills |
| **Windsurf** | Yes | — | — | MCP |
> **Full** means MCP tools + agent skills + hooks that enrich searches with graph context. **Claude Code** and **Codex** go deepest: their PreToolUse hooks enrich the search before it runs, and their PostToolUse hooks also detect a stale index after commits and prompt the agent to reindex. **Cursor**, **Antigravity**, and **Factory** augment from a post-tool hook only, so they enrich the result rather than the query and do not carry the stale-index hint.
+OpenCode's plugin uses the v1 `tool.execute.after` API: it enriches search results and adds a stale-index hint after git mutations. The integration is verified with OpenCode 1.19.5; OpenCode v2 uses a different plugin API.
+
+For Bash tools, the hooks recognize literal commands and known options; ambiguous shell constructs are skipped. Stale-index hints concern the current OpenCode project.
+
+Bash search augmentation requires `rg` or `grep` to be the first command. A search preceded by `cd`, such as `cd src && rg UserService .`, runs normally but receives no graph augmentation. Use a direct search command or OpenCode's `grep`/`glob` tools to get graph context.
+
> ¹ **Antigravity hooks** follow the [Gemini CLI hooks reference](https://geminicli.com/docs/hooks/reference/) (Antigravity 2.0 is the documented successor to Gemini CLI). Augmentation runs in `AfterTool` because `BeforeTool` has no context-injection channel in the Gemini contract — the agent sees graph context appended to the tool result via `hookSpecificOutput.additionalContext`. Stale-index hints land in the same channel after a successful `git commit/merge/rebase/cherry-pick/pull`. The schema may evolve if Antigravity-specific hook docs diverge from Gemini CLI's; the implementation will track those changes.
@@ -104,7 +110,7 @@ codex plugin marketplace add abhigyanpatwari/GitNexus
> `gitnexus setup` also merges an `AfterTool` entry into `~/.gemini/settings.json` (under the canonical [Gemini CLI hooks schema](https://geminicli.com/docs/hooks/reference/)) and installs skills to `~/.gemini/antigravity/skills/`. Existing user hooks are preserved. The hook adapter's path is rewritten at install time, so run `gitnexus setup` rather than hand-editing.
-**OpenCode** (`~/.config/opencode/config.json`):
+**OpenCode** (`~/.config/opencode/opencode.json`):
```json
{
@@ -117,6 +123,8 @@ codex plugin marketplace add abhigyanpatwari/GitNexus
}
```
+Run `gitnexus setup -c opencode` to also install skills and the auto-loaded plugin at `~/.config/opencode/plugins/gitnexus.js`. Restart OpenCode after setup. The plugin runs GitNexus asynchronously with the interpreter captured during setup, enriches `grep`/`glob`/`bash` searches, and suggests `gitnexus analyze` after a git mutation leaves the index stale. Rerun setup after moving or reinstalling GitNexus. `gitnexus uninstall --force` removes this plugin while preserving sibling plugins.
+
**CodeBuddy** (Tencent) — priority chain, edit the **first non-empty file that exists**: `~/.codebuddy/.mcp.json` (recommended) → `~/.codebuddy/mcp.json` (deprecated) → `~/.codebuddy.json` (legacy). CodeBuddy reads only the first existing file, so adding servers to a higher-priority file than the one currently in use would hide the servers below it. Create `~/.codebuddy/.mcp.json` only if none exist:
```json
diff --git a/gitnexus/README.md b/gitnexus/README.md
index 373b64e35..9c8ff87b7 100644
--- a/gitnexus/README.md
+++ b/gitnexus/README.md
@@ -135,6 +135,21 @@ Add to `~/.cursor/mcp.json` (global — works for all projects):
### OpenCode
+`gitnexus setup -c opencode` writes the MCP entry plus skills and the hooks
+plugin (`~/.config/opencode/plugins/gitnexus.js`, auto-loaded by OpenCode —
+search augmentation via the OpenCode v1 `tool.execute.after` API and a
+stale-index hint after git mutations). Restart OpenCode after setup. Verified
+with OpenCode 1.19.5; the OpenCode v2 plugin API is not covered. Bash hooks
+recognize literal commands and known options, skip ambiguous shell constructs,
+and check the current OpenCode project's index for staleness.
+
+Bash search augmentation requires `rg` or `grep` to be the first command.
+For example, `cd src && rg UserService .` runs normally but receives no graph
+augmentation. Use a direct search command or OpenCode's `grep`/`glob` tools to
+get graph context.
+
+Manual MCP configuration:
+
Add to `~/.config/opencode/config.json`:
```json
diff --git a/gitnexus/hooks/opencode/gitnexus-plugin.js b/gitnexus/hooks/opencode/gitnexus-plugin.js
new file mode 100644
index 000000000..8a02babac
--- /dev/null
+++ b/gitnexus/hooks/opencode/gitnexus-plugin.js
@@ -0,0 +1,543 @@
+/**
+ * GitNexus OpenCode plugin.
+ *
+ * Gives OpenCode the same two graph-aware behaviors the Claude Code /
+ * Antigravity hook adapters provide, through OpenCode's official plugin
+ * surface (`tool.execute.after` — see https://opencode.ai/docs/plugins/):
+ *
+ * - Search augmentation — after `grep` / `glob` / `bash` (rg/grep) tool
+ * calls, runs `gitnexus augment ` in the project directory and
+ * appends the `[GitNexus]` graph context to the tool result the agent
+ * reads. (Augmentation runs after the tool — rather than before it —
+ * because `tool.execute.before` can only rewrite args, not append result
+ * context; this mirrors the Antigravity AfterTool decision.)
+ * - Stale-index hint — after `bash` git mutation commands
+ * (commit/merge/rebase/cherry-pick/pull), runs `gitnexus status --json`
+ * and nudges the agent to re-run `gitnexus analyze` when the index is
+ * stale. A full reindex is never triggered from the hook: it would block
+ * the agent and risks DB contention on timeout.
+ *
+ * Installed by `gitnexus setup` to `~/.config/opencode/plugins/gitnexus.js`
+ * (auto-loaded by OpenCode, no config edit needed). Removed by
+ * `gitnexus uninstall --force`.
+ *
+ * Design notes:
+ * - Self-contained (node builtins only). The `hooks/claude/*.cjs` helpers
+ * are deliberately NOT reused here: files placed in OpenCode's plugin
+ * directory are auto-loaded as plugins, so co-locating helper modules
+ * risks OpenCode invoking them as plugin initializers; and the CJS/ESM
+ * boundary plus Bun/Node dual runtime makes cross-require fragile.
+ * Repo resolution, locking and DB-ownership probing are delegated to the
+ * `gitnexus` CLI itself, which already resolves the registry by cwd and
+ * fails gracefully (empty output) when there is no index.
+ * - Silent by default. Normal skip paths stay quiet unless
+ * `GITNEXUS_DEBUG=1` (strict-runner contract, same as the other
+ * adapters): a hook must never break the agent session.
+ */
+
+import fs from 'node:fs';
+import path from 'node:path';
+import { execFile } from 'node:child_process';
+
+const MARKER = '[GitNexus]';
+
+function getHereDir() {
+ try {
+ const meta = import.meta;
+ if (meta && typeof meta.dirname === 'string') return meta.dirname;
+ // Bun exposes import.meta.dir instead of import.meta.dirname.
+ if (meta && typeof meta.dir === 'string') return meta.dir;
+ } catch {
+ /* import.meta unavailable — fall through */
+ }
+ return '.';
+}
+
+const HERE_DIR = getHereDir();
+
+/** Resolved at install time by `gitnexus setup` (same patch pattern as the
+ * Claude/Antigravity adapters). Falls back to PATH lookup at runtime. */
+let cliPath = path.resolve(HERE_DIR, '..', '..', 'dist', 'cli', 'index.js');
+// Setup preserves the interpreter that installed GitNexus. In a compiled
+// OpenCode host, process.execPath is OpenCode itself, not a JS interpreter.
+let runtimePath = 'node';
+
+function isDebugEnabled() {
+ return process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
+}
+
+function debugLog(message) {
+ if (isDebugEnabled()) process.stderr.write(`[GitNexus plugin] ${message}\n`);
+}
+
+/**
+ * Keep only the `[GitNexus]`-marked payload from the CLI's stderr (the CLI
+ * writes diagnostics there too — see `augment.ts`). Returns '' when the
+ * marker is absent.
+ */
+function extractAugmentContext(stderr) {
+ const output = (stderr || '').trim();
+ const marker = output.indexOf(MARKER);
+ if (marker === -1) return '';
+ return output.slice(marker).trim();
+}
+
+/**
+ * rg/grep flags whose following argv token IS the search pattern
+ * (`rg -e foo file.ts`, `grep --regexp foo .`). They must not be lumped in
+ * with value-consuming flags — skipping the value would augment on the
+ * filename instead of the search.
+ */
+const PATTERN_FLAGS = new Set(['-e', '--regexp', '--expression']);
+
+/** Flags of rg/grep that consume the following argv token as a value. */
+const FLAGS_WITH_VALUES = new Set([
+ '-m',
+ '-A',
+ '-B',
+ '-C',
+ '-g',
+ '-M',
+ '--max-columns',
+ '--glob',
+ '-t',
+ '--type',
+ '--include',
+ '--exclude',
+]);
+
+/** Known switches without values; unfamiliar options make the query ambiguous. */
+const SEARCH_SWITCHES = new Set([
+ '-n',
+ '-i',
+ '-s',
+ '-S',
+ '-F',
+ '-w',
+ '-x',
+ '-v',
+ '-c',
+ '-l',
+ '-q',
+ '-o',
+ '-H',
+ '-u',
+ '-U',
+ '-a',
+ '-I',
+ '-P',
+ '--hidden',
+ '--no-ignore',
+ '--line-number',
+ '--column',
+ '--smart-case',
+ '--ignore-case',
+ '--fixed-strings',
+ '--word-regexp',
+ '--invert-match',
+ '--with-filename',
+ '--no-heading',
+ '--pcre2',
+]);
+
+/**
+ * Split an argv string into tokens, respecting single/double quoting so a
+ * multi-word search phrase stays one token: `rg "foo bar"` → [rg, foo bar].
+ * Only inspect the first simple command; stop at unquoted shell operators.
+ * Preserve empty arguments and decline malformed or dynamically expanded input.
+ */
+function tokenizeArgv(command, allowDynamicTail = false) {
+ const tokens = [];
+ let current = '';
+ let quote = null;
+ let started = false;
+ for (let i = 0; i < command.length; i++) {
+ const ch = command[i];
+ if (ch === '\\' && quote !== "'") {
+ const next = command[++i];
+ if (next === undefined) return [];
+ if (next === '\n') continue;
+ if (quote === '"' && !['"', '\\', '$', '`'].includes(next)) current += '\\';
+ current += next;
+ started = true;
+ continue;
+ }
+ if (quote !== "'" && (ch === '$' || ch === '`')) return allowDynamicTail ? tokens : [];
+ if (quote) {
+ if (ch === quote) quote = null;
+ else current += ch;
+ continue;
+ }
+ if (ch === '"' || ch === "'") {
+ quote = ch;
+ started = true;
+ continue;
+ }
+ if (/[;&|<>\n\r]/.test(ch) || (ch === '#' && !started)) break;
+ if (/\s/.test(ch)) {
+ if (started) {
+ tokens.push(current);
+ current = '';
+ started = false;
+ }
+ continue;
+ }
+ current += ch;
+ started = true;
+ }
+ if (quote) return [];
+ if (started) tokens.push(current);
+ return tokens;
+}
+
+/**
+ * Parse the first search positional out of an rg/grep argv string.
+ * Returns null when the command is not a search or the token is too short.
+ */
+function extractBashSearchPattern(command) {
+ if (typeof command !== 'string' || !/\brg\b|\bgrep\b/.test(command)) return null;
+ const tokens = tokenizeArgv(command);
+ if (!/(?:^|[/\\])(?:rg|grep)(?:\.exe)?$/.test(tokens[0] || '')) return null;
+ const isGrep = /(?:^|[/\\])grep(?:\.exe)?$/.test(tokens[0]);
+ let pattern = null;
+ let explicitPattern = null;
+ let endOptions = false;
+ // 'skip' = drop the next token (flag value); 'pattern' = take it as the search.
+ let pending = null;
+ for (const token of tokens.slice(1)) {
+ if (pending === 'skip') {
+ pending = null;
+ continue;
+ }
+ if (pending === 'pattern') {
+ pending = null;
+ explicitPattern ??= token;
+ continue;
+ }
+ if (!endOptions && token === '--') {
+ endOptions = true;
+ continue;
+ }
+ if (!endOptions && token.startsWith('-')) {
+ // File-backed patterns are unknown here, even if a path appeared first.
+ if (token.startsWith('-f') || token === '--file' || token.startsWith('--file=')) return null;
+ if (['--files', '--type-list', '--help', '--version'].includes(token)) return null;
+ if (PATTERN_FLAGS.has(token)) pending = 'pattern';
+ else if (/^--(?:regexp|expression)=/.test(token))
+ explicitPattern ??= token.slice(token.indexOf('=') + 1);
+ else if (token.startsWith('-e')) explicitPattern ??= token.slice(2);
+ else if (FLAGS_WITH_VALUES.has(token)) pending = 'skip';
+ else if (!isGrep && token === '-r') pending = 'skip';
+ else if (isGrep && ['-r', '-R', '-h', '--recursive'].includes(token)) continue;
+ else if (SEARCH_SWITCHES.has(token)) continue;
+ else if (token.includes('=') && FLAGS_WITH_VALUES.has(token.slice(0, token.indexOf('='))))
+ continue;
+ else return null;
+ continue;
+ }
+ pattern ??= token;
+ }
+ const result = explicitPattern ?? pattern;
+ return !pending && result?.length >= 3 ? result : null;
+}
+
+/**
+ * Extract a usable search token from an OpenCode tool invocation.
+ * Covers the built-in tools (see https://opencode.ai/docs/tools/):
+ * - `grep`: `pattern` (or `query`)
+ * - `glob`: file pattern — derive the leading literal token
+ * - `bash`: shell command — only rg/grep invocations
+ * Returns null for anything else (incl. `read`: file reads need no
+ * augmentation, matching the Claude adapter's Grep|Glob|Bash scope).
+ */
+function extractOpenCodePattern(tool, args) {
+ const input = args && typeof args === 'object' ? args : {};
+ if (tool === 'grep') {
+ const q = input.pattern ?? input.query ?? '';
+ return typeof q === 'string' && q.length >= 3 ? q : null;
+ }
+ if (tool === 'glob') {
+ const raw = typeof input.pattern === 'string' ? input.pattern : '';
+ // Longest literal run (>=3 chars) in the glob, with glob metachars as
+ // delimiters: '**/UserService*' → UserService, 'src/**/*.ts' → src,
+ // '**/*.service.ts' → service. The older [*/]letter regex missed every
+ // recursive glob whose segments start with '*', '.', or another metachar.
+ const runs = raw.match(/[a-zA-Z0-9_-]{3,}/g);
+ if (!runs) return null;
+ return runs.reduce((best, run) => (run.length > best.length ? run : best));
+ }
+ if (tool === 'bash') {
+ return extractBashSearchPattern(input.command);
+ }
+ return null;
+}
+
+/** Split simple shell commands without treating quoted text as commands. */
+function splitShellCommands(command) {
+ const segments = [];
+ let current = '';
+ let quote = null;
+ for (let i = 0; i < command.length; i++) {
+ const ch = command[i];
+ if (ch === '\\' && quote !== "'") {
+ if (i + 1 === command.length) return [];
+ current += ch + command[++i];
+ continue;
+ }
+ if (quote) {
+ if (ch === quote) quote = null;
+ current += ch;
+ continue;
+ }
+ if (ch === '"' || ch === "'") quote = ch;
+ // Heredoc bodies require a full shell parser; do not infer commands from them.
+ if (ch === '<' && command[i + 1] === '<') return [];
+ if (ch === '#' && (!current || /\s$/.test(current))) {
+ while (i < command.length && command[i] !== '\n') i++;
+ i--;
+ continue;
+ }
+ if (/[;&|\r\n]/.test(ch)) {
+ segments.push(current);
+ current = '';
+ } else current += ch;
+ }
+ if (quote) return [];
+ segments.push(current);
+ return segments;
+}
+
+const GIT_OPTIONS_WITH_VALUES = new Set([
+ '-C',
+ '-c',
+ '--git-dir',
+ '--work-tree',
+ '--namespace',
+ '--config-env',
+]);
+const GIT_SWITCHES = new Set([
+ '-p',
+ '-P',
+ '--paginate',
+ '--no-pager',
+ '--no-replace-objects',
+ '--bare',
+ '--literal-pathspecs',
+ '--glob-pathspecs',
+ '--noglob-pathspecs',
+ '--icase-pathspecs',
+ '--no-optional-locks',
+ '--no-lazy-fetch',
+ '--no-advice',
+]);
+
+/** Recognize literal Git mutation commands; the hint concerns the OpenCode project index. */
+function isGitMutationCommand(command) {
+ if (typeof command !== 'string') return false;
+ return splitShellCommands(command).some((segment) => {
+ // A dynamic commit message does not change the already-literal subcommand.
+ const tokens = tokenizeArgv(segment, true);
+ if (!/(?:^|[/\\])git(?:\.exe)?$/.test(tokens[0] || '')) return false;
+ for (let i = 1; i < tokens.length; i++) {
+ const token = tokens[i];
+ if (GIT_OPTIONS_WITH_VALUES.has(token)) {
+ if (++i === tokens.length) return false;
+ continue;
+ }
+ if (GIT_SWITCHES.has(token) || /^-[Cc].+/.test(token)) continue;
+ const equals = token.indexOf('=');
+ if (
+ equals > 0 &&
+ (GIT_OPTIONS_WITH_VALUES.has(token.slice(0, equals)) || token.startsWith('--exec-path='))
+ )
+ continue;
+ return /^(commit|merge|rebase|cherry-pick|pull)$/.test(token);
+ }
+ return false;
+ });
+}
+
+/**
+ * Resolve how to invoke the GitNexus CLI (same precedence as the Claude
+ * adapter): 1) `GITNEXUS_HOOK_CLI_PATH` when it points at an existing file
+ * (test/diagnostic override), 2) the install-patched absolute dist path,
+ * 3) a `gitnexus` binary on PATH (shell mode on Windows). Never returns
+ * null — the PATH arm is the last resort; callers still guard defensively.
+ */
+function resolveCliInvocation() {
+ const fromEnv = process.env.GITNEXUS_HOOK_CLI_PATH;
+ if (fromEnv && String(fromEnv).trim()) {
+ try {
+ if (fs.existsSync(String(fromEnv))) {
+ return { cmd: runtimePath, argv: [String(fromEnv)], useShell: false };
+ }
+ } catch {
+ /* unreadable env path — fall through */
+ }
+ }
+ try {
+ if (typeof cliPath === 'string' && cliPath && fs.existsSync(cliPath)) {
+ return { cmd: runtimePath, argv: [cliPath], useShell: false };
+ }
+ } catch {
+ /* fall through to PATH lookup */
+ }
+ return { cmd: 'gitnexus', argv: [], useShell: process.platform === 'win32' };
+}
+
+/**
+ * Shell metacharacters that would let a search pattern inject commands when
+ * the CLI must be invoked through a shell (the Windows PATH fallback — the
+ * only useShell arm; node+dist and non-Windows PATH arms never use a shell).
+ * A pattern containing any of these is dropped: a skipped augment is always
+ * safe, a successfully injected command is not.
+ */
+const SHELL_UNSAFE_RE = /[&|<>^"`$;()\r\n%']/;
+
+/** True when pattern must not be forwarded through a shell invocation. */
+function isShellMetaUnsafe(pattern) {
+ return typeof pattern === 'string' && SHELL_UNSAFE_RE.test(pattern);
+}
+
+/**
+ * Shell-mode guard used by runAugment: refuse to spawn when the resolved
+ * invocation needs a shell AND the pattern carries metacharacters.
+ */
+function shouldSkipAugmentForShell(invocation, pattern) {
+ return Boolean(invocation && invocation.useShell && isShellMetaUnsafe(pattern));
+}
+
+// Hooks share OpenCode's event loop. Wait asynchronously for the bounded CLI
+// process so a slow index cannot freeze the UI or other sessions.
+function runCli(invocation, args, cwd, timeout) {
+ return new Promise((resolve, reject) => {
+ execFile(
+ invocation.cmd,
+ [...invocation.argv, ...args],
+ {
+ encoding: 'utf-8',
+ timeout,
+ maxBuffer: 1024 * 1024,
+ cwd,
+ windowsHide: true,
+ ...(invocation.useShell ? { shell: true } : {}),
+ },
+ (error, stdout, stderr) => {
+ if (error) reject(error);
+ else resolve({ stdout, stderr });
+ },
+ );
+ });
+}
+
+/**
+ * Run `gitnexus augment ` in `cwd` and return the marked context,
+ * or '' on any failure (no repo, no index, lock contention, timeout).
+ */
+async function runAugment(pattern, cwd) {
+ const invocation = resolveCliInvocation();
+ if (!invocation) return '';
+ // The pattern originates from tool arguments (agent- and repo-influenced).
+ // Never forward it through a shell: cmd.exe would re-parse metacharacters
+ // (`rg -e foo^& del /f ...`) into extra commands.
+ if (shouldSkipAugmentForShell(invocation, pattern)) return '';
+ // Shell mode joins argv with bare spaces (no quoting), so a multi-word
+ // phrase must be wrapped to stay one argument. Safe: the guard above has
+ // already dropped any pattern containing a quote or other metacharacter.
+ const forwarded = invocation.useShell ? `"${pattern}"` : pattern;
+ try {
+ const child = await runCli(invocation, ['augment', '--', forwarded], cwd, 10000);
+ return extractAugmentContext(child.stderr || '');
+ } catch {
+ return '';
+ }
+}
+
+/**
+ * Parse `gitnexus status --json` stdout. Returns the parsed payload or null.
+ */
+function parseStatusPayload(stdout) {
+ try {
+ const payload = JSON.parse(String(stdout || ''));
+ if (payload && typeof payload === 'object') return payload;
+ } catch {
+ /* not JSON — no index / CLI error */
+ }
+ return null;
+}
+
+/**
+ * Run `gitnexus status --json` in `cwd`. Returns 'stale' only on a positive
+ * stale verdict; every other outcome (up-to-date, not indexed, error) is
+ * null so the hook stays silent. Cheap on the stale path: when HEAD moved,
+ * the metadata check already disagrees and the content scan is skipped.
+ */
+async function checkIndexStale(cwd) {
+ const invocation = resolveCliInvocation();
+ if (!invocation) return null;
+ try {
+ const child = await runCli(invocation, ['status', '--json'], cwd, 15000);
+ const payload = parseStatusPayload(child.stdout || '');
+ if (!payload || typeof payload.status !== 'string') return null;
+ if (payload.status !== 'stale') return null;
+ const indexed = payload?.index?.commit ? String(payload.index.commit).slice(0, 7) : '';
+ return (
+ `${MARKER} GitNexus index is stale` +
+ (indexed ? ` (last indexed: ${indexed})` : '') +
+ '. Run `gitnexus analyze` to update the knowledge graph.'
+ );
+ } catch {
+ return null;
+ }
+}
+
+function appendOutput(output, text) {
+ if (!text) return;
+ if (typeof output.output === 'string' && output.output.length > 0) {
+ output.output += `\n\n${text}`;
+ } else if (typeof output.output === 'string') {
+ output.output = text;
+ }
+ // Non-string tool results are left untouched — never coerce agent data.
+}
+
+/**
+ * `tool.execute.after` handler: augment searches, hint on stale index.
+ * `directory` is the project directory captured at plugin init.
+ */
+function createAfterHook(directory) {
+ return async (input, output) => {
+ try {
+ if (!input || !output || typeof output !== 'object') return;
+ const tool = String(input.tool || '').toLowerCase();
+ const args = input.args && typeof input.args === 'object' ? input.args : {};
+ const cwd = typeof directory === 'string' && directory ? directory : process.cwd();
+
+ if (tool === 'grep' || tool === 'glob' || tool === 'bash') {
+ const pattern = extractOpenCodePattern(tool, args);
+ if (pattern) {
+ const context = await runAugment(pattern, cwd);
+ if (context) appendOutput(output, context);
+ }
+ if (tool === 'bash' && isGitMutationCommand(args.command)) {
+ const hint = await checkIndexStale(cwd);
+ if (hint) appendOutput(output, hint);
+ }
+ }
+ } catch (err) {
+ debugLog(`after-hook failed: ${(err && err.message) || err}`);
+ }
+ };
+}
+
+// Every export is invoked as a plugin initializer by OpenCode's loader.
+export const GitNexusPlugin = async ({ directory, worktree } = {}) => {
+ const projectDir =
+ (typeof directory === 'string' && directory) ||
+ (typeof worktree === 'string' && worktree) ||
+ process.cwd();
+ debugLog(`initialized for ${projectDir}`);
+ return {
+ 'tool.execute.after': createAfterHook(projectDir),
+ };
+};
diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts
index 3b9c0c14b..f12441517 100644
--- a/gitnexus/scripts/cross-platform-tests.ts
+++ b/gitnexus/scripts/cross-platform-tests.ts
@@ -31,6 +31,9 @@ const PLATFORM_LOGIC = [
'test/unit/setup-codex.test.ts',
'test/unit/setup-antigravity.test.ts',
'test/integration/setup-uninstall-roundtrip.test.ts',
+ 'test/unit/opencode-plugin.test.ts',
+ 'test/integration/opencode-plugin.test.ts',
+ 'test/unit/uninstall.test.ts',
'test/unit/resolve-invocation.test.ts',
// CLI-spawn entry-point resolution; its path-separator assertion (cli[/\\]index)
// must exercise the Windows backslash branch, so run it on the OS matrix (#2394).
diff --git a/gitnexus/src/cli/editor-targets.ts b/gitnexus/src/cli/editor-targets.ts
index f3267aa4b..ba320d171 100644
--- a/gitnexus/src/cli/editor-targets.ts
+++ b/gitnexus/src/cli/editor-targets.ts
@@ -82,6 +82,19 @@ export interface HookTarget {
scriptDir: string;
}
+/**
+ * An editor whose hooks ship as a plugin file auto-loaded from a plugins
+ * directory (no settings-file entry). Currently OpenCode only: its official
+ * plugin surface (`tool.execute.after` in `~/.config/opencode/plugins/`)
+ * needs no config edit — dropping the file in is the whole registration.
+ */
+export interface PluginFileTarget {
+ id: EditorId;
+ label: string;
+ /** Absolute path to the installed gitnexus plugin file. */
+ file: string;
+}
+
export interface EditorTargets {
/** JSONC-format MCP entries: Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder. */
mcpJsonc: McpJsoncTarget[];
@@ -91,6 +104,8 @@ export interface EditorTargets {
skills: SkillTarget[];
/** Hook registrations + their bundled script directories. */
hooks: HookTarget[];
+ /** Plugin-file hooks (auto-loaded, no settings entry). */
+ plugins: PluginFileTarget[];
}
/**
@@ -223,7 +238,15 @@ export function getEditorTargets(home: string = os.homedir()): EditorTargets {
},
];
- return { mcpJsonc, codex, skills, hooks };
+ const plugins: PluginFileTarget[] = [
+ {
+ id: 'opencode',
+ label: 'OpenCode',
+ file: path.join(home, '.config', 'opencode', 'plugins', 'gitnexus.js'),
+ },
+ ];
+
+ return { mcpJsonc, codex, skills, hooks, plugins };
}
/** Look up a single JSONC MCP target by editor id (throws if unknown). */
@@ -247,6 +270,13 @@ export function hookTarget(id: EditorId, home?: string): HookTarget {
return t;
}
+/** Look up a single plugin-file target by editor id (throws if unknown). */
+export function pluginFileTarget(id: EditorId, home?: string): PluginFileTarget {
+ const t = getEditorTargets(home).plugins.find((p) => p.id === id);
+ if (!t) throw new Error(`No plugin target for editor "${id}"`);
+ return t;
+}
+
/**
* True when err is a Node fs error with code ENOENT (file/dir absent).
* Shared by setup and uninstall: both must swallow ONLY absence when reading
diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts
index 33de0ad6b..f55c4680d 100644
--- a/gitnexus/src/cli/setup.ts
+++ b/gitnexus/src/cli/setup.ts
@@ -29,6 +29,7 @@ import {
mcpTarget,
skillTarget,
hookTarget,
+ pluginFileTarget,
detectIndentation,
isEnoent,
type EditorId,
@@ -91,6 +92,12 @@ export function formatHookCommand(
const CLI_PATH_SOURCE_LITERAL =
"let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');";
+// Same install-time patch contract as CLI_PATH_SOURCE_LITERAL, for the ESM
+// OpenCode plugin (which has no __dirname and resolves from import.meta).
+const OPENCODE_CLI_PATH_SOURCE_LITERAL =
+ "let cliPath = path.resolve(HERE_DIR, '..', '..', 'dist', 'cli', 'index.js');";
+const OPENCODE_RUNTIME_PATH_SOURCE_LITERAL = "let runtimePath = 'node';";
+
interface SetupResult {
configured: string[];
skipped: string[];
@@ -1321,6 +1328,57 @@ async function installOpenCodeSkills(result: SetupResult): Promise {
}
}
+/**
+ * Install the global OpenCode hooks plugin to ~/.config/opencode/plugins/gitnexus.js.
+ *
+ * Unlike the Claude/Codex/Antigravity adapters (registered as command hooks in
+ * a settings file), OpenCode auto-loads plugin files from its plugins
+ * directory — dropping the file in IS the registration, no config edit
+ * needed (https://opencode.ai/docs/plugins/). The plugin implements
+ * `tool.execute.after`: graph-context augmentation after grep/glob/bash
+ * searches plus a stale-index hint after git mutations.
+ */
+async function installOpenCodePlugin(result: SetupResult): Promise {
+ const opencodeDir = path.join(os.homedir(), '.config', 'opencode');
+ if (!(await dirExists(opencodeDir))) return;
+
+ const dest = pluginFileTarget('opencode').file;
+ const src = path.join(__dirname, '..', '..', 'hooks', 'opencode', 'gitnexus-plugin.js');
+ try {
+ await fs.mkdir(path.dirname(dest), { recursive: true });
+ let content = await fs.readFile(src, 'utf-8');
+ const resolvedCli = path.join(__dirname, '..', 'cli', 'index.js');
+ const normalizedCli = path.resolve(resolvedCli).replace(/\\/g, '/');
+ const jsonCli = JSON.stringify(normalizedCli);
+ if (
+ !content.includes(OPENCODE_CLI_PATH_SOURCE_LITERAL) ||
+ !content.includes(OPENCODE_RUNTIME_PATH_SOURCE_LITERAL)
+ ) {
+ result.errors.push(
+ 'OpenCode hooks: gitnexus-plugin.js no longer contains the CLI/runtime path literals to patch — skipping plugin install. Update the OPENCODE_*_SOURCE_LITERAL constants in setup.ts.',
+ );
+ return;
+ }
+ content = content
+ .replace(OPENCODE_CLI_PATH_SOURCE_LITERAL, () => `let cliPath = ${jsonCli};`)
+ .replace(
+ OPENCODE_RUNTIME_PATH_SOURCE_LITERAL,
+ () => `let runtimePath = ${JSON.stringify(process.execPath)};`,
+ );
+ // Sanity: the installed copy must export the plugin initializer OpenCode calls.
+ if (!content.includes('GitNexusPlugin')) {
+ result.errors.push(
+ 'OpenCode hooks: gitnexus-plugin.js no longer exports GitNexusPlugin — skipping plugin install',
+ );
+ return;
+ }
+ await fs.writeFile(dest, content, 'utf-8');
+ result.configured.push('OpenCode hooks (plugin → ~/.config/opencode/plugins/gitnexus.js)');
+ } catch (err: any) {
+ result.errors.push(`OpenCode hooks: ${err.message}`);
+ }
+}
+
/**
* Install global Codex skills to ~/.agents/skills/gitnexus/
*/
@@ -1381,7 +1439,10 @@ export const setupCommand = async (options?: { codingAgent?: string[] | string }
await installAntigravityHooks(result);
}
if (selected.has('cursor')) await installCursorSkills(result);
- if (selected.has('opencode')) await installOpenCodeSkills(result);
+ if (selected.has('opencode')) {
+ await installOpenCodeSkills(result);
+ await installOpenCodePlugin(result);
+ }
if (selected.has('codebuddy')) await installCodeBuddySkills(result);
if (selected.has('qoder')) await installQoderSkills(result);
if (selected.has('codex')) {
diff --git a/gitnexus/src/cli/uninstall.ts b/gitnexus/src/cli/uninstall.ts
index 4a278a56b..478ea94fb 100644
--- a/gitnexus/src/cli/uninstall.ts
+++ b/gitnexus/src/cli/uninstall.ts
@@ -196,6 +196,29 @@ async function removeHookEntries(
return { status: 'removed', count: total };
}
+/**
+ * Remove a single file if it exists. Returns true when something was
+ * (or would be) removed. Unlike removeDir, refuses to touch directories —
+ * a plugin target must always resolve to a file.
+ */
+async function removeFile(filePath: string, dryRun: boolean): Promise {
+ try {
+ // lstat, not stat: stat follows symlinks, so a dangling plugin symlink
+ // would throw ENOENT and be treated as absent — surviving uninstall at
+ // the auto-loaded plugins path. A symlink (dangling or not) is itself
+ // the thing to unlink; rm never dereferences it.
+ const stat = await fs.lstat(filePath);
+ if (!stat.isFile() && !stat.isSymbolicLink()) return false;
+ } catch (err) {
+ // Only "not there" is a clean no-op; anything else (EACCES, EIO, ...)
+ // must surface — the caller records it in result.errors.
+ if (isEnoent(err)) return false;
+ throw err;
+ }
+ if (!dryRun) await fs.rm(filePath, { force: true });
+ return true;
+}
+
/**
* Remove a directory tree if it exists. Returns true when something was
* (or would be) removed.
@@ -522,6 +545,19 @@ export const uninstallCommand = async (options?: { force?: boolean }) => {
}
}
+ // ─── Plugin-file hooks (auto-loaded, no settings entry) ─────────
+ // Only the gitnexus-owned plugin file is removed; sibling user plugins in
+ // the same directory are never touched.
+ for (const plugin of targets.plugins) {
+ try {
+ if (await removeFile(plugin.file, dryRun)) {
+ result.removed.push(`${plugin.label} hooks plugin — ${plugin.file}`);
+ }
+ } catch (err: any) {
+ result.errors.push(`${plugin.label} hooks plugin: ${err.message}`);
+ }
+ }
+
// ─── Skills ──────────────────────────────────────────────────────
// Skill directories are identified by the bundled gitnexus skill names; the
// exact paths are listed below so the user can see what will be removed.
diff --git a/gitnexus/test/integration/opencode-plugin.test.ts b/gitnexus/test/integration/opencode-plugin.test.ts
new file mode 100644
index 000000000..581c1fa0e
--- /dev/null
+++ b/gitnexus/test/integration/opencode-plugin.test.ts
@@ -0,0 +1,120 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { execFile } from 'node:child_process';
+import { promisify } from 'node:util';
+import { pathToFileURL } from 'node:url';
+
+const exec = promisify(execFile);
+const cli = path.resolve('dist/cli/index.js');
+
+describe('installed OpenCode plugin', () => {
+ let home: string;
+ let plugin: string;
+ let env: NodeJS.ProcessEnv;
+
+ beforeEach(async () => {
+ home = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-opencode ')));
+ plugin = path.join(home, '.config', 'opencode', 'plugins', 'gitnexus.js');
+ await fs.mkdir(path.dirname(plugin), { recursive: true });
+ await fs.writeFile(path.join(path.dirname(plugin), 'mine.js'), '// user plugin');
+ const skills = path.join(home, 'skills');
+ await fs.mkdir(skills);
+ env = {
+ ...process.env,
+ HOME: home,
+ USERPROFILE: home,
+ GITNEXUS_HOME: path.join(home, 'registry'),
+ GITNEXUS_TEST_SKILLS_ROOT: skills,
+ };
+ // Uninstall visits every editor; an inherited override would escape this fixture.
+ delete env.CODEX_HOME;
+ await exec(process.execPath, [cli, 'setup', '-c', 'opencode'], { env, cwd: home });
+ });
+
+ afterEach(async () => {
+ await fs.rm(home, { recursive: true, force: true });
+ });
+
+ it("exposes only a plugin initializer to OpenCode's module loader", async () => {
+ const { stdout } = await exec(
+ process.execPath,
+ [
+ '--input-type=module',
+ '-e',
+ `
+ const mod = await import(${JSON.stringify(pathToFileURL(plugin).href)});
+ const hooks = [];
+ // OpenCode v1's legacy loader invokes every exported function.
+ for (const init of new Set(Object.values(mod))) hooks.push(await init({ directory: process.cwd() }));
+ console.log(JSON.stringify({ exports: Object.keys(mod), hooks: hooks.map(Object.keys) }));
+ `,
+ ],
+ { env, cwd: home },
+ );
+ expect(JSON.parse(stdout)).toEqual({
+ exports: ['GitNexusPlugin'],
+ hooks: [['tool.execute.after']],
+ });
+ });
+
+ it('runs the CLI outside the host executable, yields to the event loop, and uninstalls surgically', async () => {
+ const fakeCli = path.join(home, 'fake cli.cjs');
+ const calls = path.join(home, 'calls.jsonl');
+ await fs.writeFile(
+ fakeCli,
+ `
+ require('node:fs').appendFileSync(${JSON.stringify(calls)}, JSON.stringify({ argv: process.argv.slice(2), cwd: process.cwd() }) + '\\n');
+ setTimeout(() => {
+ if (process.argv[2] === 'augment') process.stderr.write('[GitNexus] callers: A');
+ else process.stdout.write(JSON.stringify({ status: 'stale', index: { commit: 'abc1234567' } }));
+ }, 30);
+ `,
+ );
+ const { stdout } = await exec(
+ process.execPath,
+ [
+ '--input-type=module',
+ '-e',
+ `
+ const { GitNexusPlugin } = await import(${JSON.stringify(pathToFileURL(plugin).href)});
+ // Compiled OpenCode reports its own binary here, not Node or Bun.
+ Object.defineProperty(process, 'execPath', { value: '/missing/opencode' });
+ const hooks = await GitNexusPlugin({ directory: process.cwd() });
+ let responsive = false;
+ const timer = setTimeout(() => { responsive = true; }, 0);
+ const search = { output: 'hits' };
+ await hooks['tool.execute.after']({ tool: 'grep', args: { pattern: 'User Service' } }, search);
+ clearTimeout(timer);
+ const mutation = { output: 'committed' };
+ await hooks['tool.execute.after']({ tool: 'bash', args: { command: 'git commit -m test' } }, mutation);
+ console.log(JSON.stringify({ search, mutation, responsive }));
+ `,
+ ],
+ { env: { ...env, GITNEXUS_HOOK_CLI_PATH: fakeCli }, cwd: home },
+ );
+ expect(JSON.parse(stdout)).toEqual({
+ search: { output: 'hits\n\n[GitNexus] callers: A' },
+ mutation: {
+ output:
+ 'committed\n\n[GitNexus] GitNexus index is stale (last indexed: abc1234). Run `gitnexus analyze` to update the knowledge graph.',
+ },
+ responsive: true,
+ });
+ expect(
+ (await fs.readFile(calls, 'utf8'))
+ .trim()
+ .split('\n')
+ .map((line) => JSON.parse(line)),
+ ).toEqual([
+ { argv: ['augment', '--', 'User Service'], cwd: home },
+ { argv: ['status', '--json'], cwd: home },
+ ]);
+ await exec(process.execPath, [cli, 'uninstall', '--force'], { env, cwd: home });
+ await expect(fs.access(plugin)).rejects.toMatchObject({ code: 'ENOENT' });
+ expect(await fs.readFile(path.join(path.dirname(plugin), 'mine.js'), 'utf8')).toBe(
+ '// user plugin',
+ );
+ });
+});
diff --git a/gitnexus/test/integration/setup-uninstall-roundtrip.test.ts b/gitnexus/test/integration/setup-uninstall-roundtrip.test.ts
index 100b1007a..e1222c9d5 100644
--- a/gitnexus/test/integration/setup-uninstall-roundtrip.test.ts
+++ b/gitnexus/test/integration/setup-uninstall-roundtrip.test.ts
@@ -146,6 +146,12 @@ describe('setup → uninstall round-trip', () => {
).toBe(true);
expect(await exists(h.scriptDir), `setup should install ${h.label} hook scripts`).toBe(true);
}
+ for (const p of targets.plugins) {
+ expect(await exists(p.file), `setup should install ${p.label} plugin`).toBe(true);
+ const content = await fs.readFile(p.file, 'utf-8');
+ expect(content).toContain('GitNexusPlugin');
+ expect(content).toContain('tool.execute.after');
+ }
// ── Round-trip: uninstall removes everything setup wrote ──
const { uninstallCommand } = await import('../../src/cli/uninstall.js');
@@ -176,6 +182,9 @@ describe('setup → uninstall round-trip', () => {
false,
);
}
+ for (const p of targets.plugins) {
+ expect(await exists(p.file), `uninstall should remove ${p.label} plugin`).toBe(false);
+ }
});
it('round-trips a CodeBuddy entry living in the home-level legacy ~/.codebuddy.json (chain position 3)', async () => {
diff --git a/gitnexus/test/unit/opencode-plugin.test.ts b/gitnexus/test/unit/opencode-plugin.test.ts
new file mode 100644
index 000000000..41f280f40
--- /dev/null
+++ b/gitnexus/test/unit/opencode-plugin.test.ts
@@ -0,0 +1,284 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import fs from 'node:fs';
+import { GitNexusPlugin } from '../../hooks/opencode/gitnexus-plugin.js';
+
+const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }));
+vi.mock('node:child_process', () => ({ execFile: execFileMock }));
+
+type Callback = (error: Error | null, stdout: string, stderr: string) => void;
+const platform = Object.getOwnPropertyDescriptor(process, 'platform')!;
+
+async function invoke(tool: string, args: unknown, value: unknown = 'hits') {
+ const hooks = await GitNexusPlugin({ directory: '/repo' });
+ const output = { output: value };
+ await hooks['tool.execute.after']({ tool, args }, output);
+ return output.output;
+}
+
+beforeEach(() => {
+ vi.stubEnv('GITNEXUS_HOOK_CLI_PATH', '');
+ execFileMock.mockReset();
+ execFileMock.mockImplementation(
+ (_cmd: string, argv: string[], _options: unknown, callback: Callback) => {
+ callback(
+ null,
+ argv.includes('status') ? '{"status":"up-to-date"}' : '',
+ '[GitNexus] callers: A',
+ );
+ },
+ );
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllEnvs();
+ Object.defineProperty(process, 'platform', platform);
+});
+
+describe('search augmentation through the public hook', () => {
+ it.each([
+ ['grep', { pattern: 'UserService' }, 'UserService'],
+ ['grep', { query: 'fallback' }, 'fallback'],
+ ['glob', { pattern: '**/UserService*' }, 'UserService'],
+ ['glob', { pattern: 'src/**/*.ts' }, 'src'],
+ ['glob', { pattern: '**/*.service.ts' }, 'service'],
+ ['bash', { command: 'rg --type ts UserService .' }, 'UserService'],
+ ['bash', { command: 'grep -r UserService .' }, 'UserService'],
+ ['bash', { command: 'rg -n -e UserService setup.ts' }, 'UserService'],
+ ['bash', { command: 'rg --regexp UserService .' }, 'UserService'],
+ ['bash', { command: 'grep --expression UserService .' }, 'UserService'],
+ ['bash', { command: 'rg "foo bar" .' }, 'foo bar'],
+ ['bash', { command: "grep -r 'user service' src" }, 'user service'],
+ ['bash', { command: 'rg -e "foo bar" file.ts' }, 'foo bar'],
+ ['bash', { command: 'rg --max-columns 100 UserService .' }, 'UserService'],
+ ['bash', { command: 'rg --max-columns=100 UserService .' }, 'UserService'],
+ ['bash', { command: 'rg -M 100 UserService .' }, 'UserService'],
+ ['bash', { command: 'rg -- -foobar file.ts' }, '-foobar'],
+ ['bash', { command: 'grep -- -foobar file.ts' }, '-foobar'],
+ ['bash', { command: '/usr/bin/rg UserService .' }, 'UserService'],
+ ['bash', { command: 'rg "foo;bar" file.ts | head' }, 'foo;bar'],
+ ['bash', { command: 'rg UserService . && echo done' }, 'UserService'],
+ ['bash', { command: 'rg --regexp=UserService .' }, 'UserService'],
+ ['bash', { command: 'rg -eUserService .' }, 'UserService'],
+ ['bash', { command: 'rg src -e UserService' }, 'UserService'],
+ ['bash', { command: 'rg foo\\ bar src' }, 'foo bar'],
+ ['bash', { command: 'rg "foo\\\"bar" src' }, 'foo"bar'],
+ ['bash', { command: 'rg "foo\\dbar" src' }, 'foo\\dbar'],
+ ['bash', { command: "rg '$literal' src" }, '$literal'],
+ ['bash', { command: 'rg UserService # comment' }, 'UserService'],
+ ['bash', { command: 'rg \\\nUserService src' }, 'UserService'],
+ ['bash', { command: 'rg -r replacement UserService src' }, 'UserService'],
+ ['bash', { command: 'rg --hidden -i UserService src' }, 'UserService'],
+ ])('augments %s %j with %s', async (tool, args, pattern) => {
+ expect(await invoke(tool, args)).toBe('hits\n\n[GitNexus] callers: A');
+ expect(execFileMock).toHaveBeenCalledTimes(1);
+ expect(execFileMock.mock.calls[0][1].slice(-3)).toEqual(['augment', '--', pattern]);
+ expect(execFileMock.mock.calls[0][2]).toMatchObject({
+ cwd: '/repo',
+ timeout: 10000,
+ maxBuffer: 1024 * 1024,
+ });
+ });
+
+ it.each([
+ ['grep', { pattern: 'ab' }],
+ ['grep', {}],
+ ['grep', null],
+ ['grep', 'UserService'],
+ ['glob', { pattern: '**/*.{a,b}' }],
+ ['glob', {}],
+ ['bash', { command: 'rg -n "ab" .' }],
+ ['bash', { command: 'npm test' }],
+ ['bash', { command: 'echo rg UserService' }],
+ ['bash', { command: 'printf "rg UserService"' }],
+ ['bash', { command: 'rg -f patterns.txt src' }],
+ ['bash', { command: 'rg --file patterns.txt src' }],
+ ['bash', { command: 'rg --file=patterns.txt src' }],
+ ['bash', { command: 'rg -fpatterns.txt src' }],
+ ['bash', { command: 'rg src -f patterns.txt' }],
+ ['bash', { command: 'rg --files src' }],
+ ['bash', { command: 'rg -n; echo UserService' }],
+ ['bash', { command: 'rg -n\necho UserService' }],
+ ['bash', { command: 'rg "" src' }],
+ ['bash', { command: 'rg "UserService src' }],
+ ['bash', { command: 'rg $pattern src' }],
+ ['bash', { command: 'rg "${pattern}" src' }],
+ ['bash', { command: 'rg `query` src' }],
+ ['bash', { command: 'rg UserService\\' }],
+ ['bash', { command: 'rg -n # UserService' }],
+ ['bash', { command: 'rg --unknown-option value UserService src' }],
+ ['bash', { command: 'rg --sort path UserService src' }],
+ ['bash', { command: 'rg --iglob "*.ts" UserService src' }],
+ ['bash', { command: 'rg -rreplacement UserService src' }],
+ ['bash', { command: null }],
+ ['bash', {}],
+ ['read', { filePath: 'a.ts' }],
+ ['edit', { filePath: 'a.ts' }],
+ ])('leaves %s %j untouched', async (tool, args) => {
+ expect(await invoke(tool, args)).toBe('hits');
+ expect(execFileMock).not.toHaveBeenCalled();
+ });
+
+ it('strips diagnostics before the context marker and handles an empty tool result', async () => {
+ execFileMock.mockImplementation(
+ (_cmd: string, _argv: string[], _options: unknown, callback: Callback) =>
+ callback(null, '', 'diagnostic\n[GitNexus] callers: A\n'),
+ );
+ expect(await invoke('grep', { pattern: 'UserService' }, '')).toBe('[GitNexus] callers: A');
+ });
+
+ it('discards unmarked diagnostics and never coerces structured results', async () => {
+ execFileMock.mockImplementation(
+ (_cmd: string, _argv: string[], _options: unknown, callback: Callback) =>
+ callback(null, '', 'diagnostic'),
+ );
+ expect(await invoke('grep', { pattern: 'UserService' })).toBe('hits');
+ expect(await invoke('grep', { pattern: 'UserService' }, { structured: true })).toEqual({
+ structured: true,
+ });
+ });
+});
+
+describe('stale-index hints', () => {
+ it.each([
+ 'git commit -m x',
+ 'git merge main',
+ 'git rebase main',
+ 'git cherry-pick abc',
+ 'git pull',
+ 'cd src && git commit -m x',
+ 'npm test || git pull',
+ 'git status\ngit commit -m x',
+ 'git status\r\ngit merge main',
+ 'git -C /repo commit -m x',
+ 'git -C/repo -c user.name=Agent commit -m x',
+ 'git -cuser.name=Agent --no-pager commit -m x',
+ 'git --git-dir /repo/.git --work-tree=/repo commit -m x',
+ 'git --config-env user.name=GIT_AUTHOR_NAME commit -m x',
+ 'git commit -m "$MESSAGE"',
+ 'git commit -m "text; no second command"',
+ 'echo "text;git status"; git commit -m x',
+ 'git \\\ncommit -m x',
+ '/usr/bin/git commit -m x',
+ ])('checks %s', async (command) => {
+ execFileMock.mockImplementation(
+ (_cmd: string, _argv: string[], _options: unknown, callback: Callback) =>
+ callback(null, '{"status":"stale","index":{"commit":"abc1234567"}}', ''),
+ );
+ expect(await invoke('bash', { command })).toBe(
+ 'hits\n\n[GitNexus] GitNexus index is stale (last indexed: abc1234). Run `gitnexus analyze` to update the knowledge graph.',
+ );
+ expect(execFileMock.mock.calls[0][1].slice(-2)).toEqual(['status', '--json']);
+ expect(execFileMock.mock.calls[0][2]).toMatchObject({ timeout: 15000 });
+ });
+
+ it.each([
+ 'git status',
+ 'git diff',
+ 'git stash push',
+ "echo 'git commit'",
+ "grep 'git commit' file.txt",
+ 'cat notes.md | grep git-pull',
+ "echo 'text;git commit -m x'",
+ 'echo "text|git commit -m x"',
+ "echo 'text\ngit commit -m x'",
+ 'echo text\\;git commit -m x',
+ 'echo text # ; git commit -m x',
+ 'cat < {
+ await invoke('bash', { command });
+ expect(execFileMock.mock.calls.filter((call) => call[1].includes('status'))).toEqual([]);
+ });
+
+ it.each(['{"status":"up-to-date"}', '{"error":"not-indexed"}', 'not json', '', 'null'])(
+ 'stays silent for %s',
+ async (stdout) => {
+ execFileMock.mockImplementation(
+ (_cmd: string, _argv: string[], _options: unknown, callback: Callback) =>
+ callback(null, stdout, ''),
+ );
+ expect(await invoke('bash', { command: 'git commit -m x' })).toBe('hits');
+ },
+ );
+});
+
+describe('CLI boundaries', () => {
+ it('uses the CLI override with an interpreter, never the OpenCode host binary', async () => {
+ vi.stubEnv('GITNEXUS_HOOK_CLI_PATH', '/custom/cli.js');
+ vi.spyOn(fs, 'existsSync').mockReturnValue(true);
+ await invoke('grep', { pattern: 'UserService' });
+ expect(execFileMock.mock.calls[0].slice(0, 2)).toEqual([
+ 'node',
+ ['/custom/cli.js', 'augment', '--', 'UserService'],
+ ]);
+ expect(execFileMock.mock.calls[0][2].shell).toBeUndefined();
+ });
+
+ it.each([
+ 'foo & calc',
+ 'foo|bar',
+ 'a > b',
+ 'a < b',
+ 'x^&y',
+ 'a"b',
+ "a'b",
+ 'a`b',
+ 'a$b',
+ 'a;b',
+ 'a(b)',
+ 'a%PATH%',
+ 'a\nb',
+ 'a\rb',
+ ])('drops unsafe Windows shell input %s', async (pattern) => {
+ vi.spyOn(fs, 'existsSync').mockReturnValue(false);
+ Object.defineProperty(process, 'platform', { value: 'win32' });
+ expect(await invoke('grep', { pattern })).toBe('hits');
+ expect(execFileMock).not.toHaveBeenCalled();
+ });
+
+ it('quotes multi-word Windows PATH fallback arguments', async () => {
+ vi.spyOn(fs, 'existsSync').mockReturnValue(false);
+ Object.defineProperty(process, 'platform', { value: 'win32' });
+ await invoke('grep', { pattern: 'User Service' });
+ expect(execFileMock.mock.calls[0].slice(0, 2)).toEqual([
+ 'gitnexus',
+ ['augment', '--', '"User Service"'],
+ ]);
+ expect(execFileMock.mock.calls[0][2].shell).toBe(true);
+ });
+
+ it('passes metacharacters literally without a shell when the CLI file exists', async () => {
+ vi.stubEnv('GITNEXUS_HOOK_CLI_PATH', '/custom/cli.js');
+ vi.spyOn(fs, 'existsSync').mockReturnValue(true);
+ await invoke('grep', { pattern: 'foo & calc' });
+ expect(execFileMock.mock.calls[0][1].slice(-3)).toEqual(['augment', '--', 'foo & calc']);
+ expect(execFileMock.mock.calls[0][2].shell).toBeUndefined();
+ });
+
+ it.each(['timeout', 'nonzero exit', 'buffer exceeded'])(
+ 'preserves output after %s',
+ async (reason) => {
+ execFileMock.mockImplementation(
+ (_cmd: string, _argv: string[], _options: unknown, callback: Callback) =>
+ callback(new Error(reason), '', '[GitNexus] incomplete'),
+ );
+ expect(await invoke('grep', { pattern: 'UserService' })).toBe('hits');
+ expect(await invoke('bash', { command: 'git commit -m x' })).toBe('hits');
+ },
+ );
+
+ it('never throws for a synchronous spawn error or malformed hook arguments', async () => {
+ execFileMock.mockImplementation(() => {
+ throw new Error('spawn failed');
+ });
+ expect(await invoke('grep', { pattern: 'UserService' })).toBe('hits');
+ const hooks = await GitNexusPlugin();
+ await expect(hooks['tool.execute.after'](null, null)).resolves.toBeUndefined();
+ });
+});
diff --git a/gitnexus/test/unit/uninstall.test.ts b/gitnexus/test/unit/uninstall.test.ts
index 5c3a2e285..7d47928c4 100644
--- a/gitnexus/test/unit/uninstall.test.ts
+++ b/gitnexus/test/unit/uninstall.test.ts
@@ -418,6 +418,83 @@ describe('uninstallCommand', () => {
expect(raw).toContain('"gitnexus"');
});
+ // ── OpenCode hooks plugin: a single auto-loaded file ──
+ it('removes the OpenCode hooks plugin file, preserving sibling plugins', async () => {
+ const pluginFile = path.join(tempHome, '.config', 'opencode', 'plugins', 'gitnexus.js');
+ const sibling = path.join(tempHome, '.config', 'opencode', 'plugins', 'mine.js');
+ await fs.mkdir(path.dirname(pluginFile), { recursive: true });
+ await fs.writeFile(pluginFile, '// gitnexus', 'utf-8');
+ await fs.writeFile(sibling, '// mine', 'utf-8');
+
+ const uninstallCommand = await importUninstall();
+ await uninstallCommand({ force: true });
+
+ await expect(fs.access(pluginFile)).rejects.toThrow();
+ expect(await fs.readFile(sibling, 'utf-8')).toBe('// mine');
+ });
+
+ it('dry-run keeps the OpenCode hooks plugin file', async () => {
+ const pluginFile = path.join(tempHome, '.config', 'opencode', 'plugins', 'gitnexus.js');
+ await fs.mkdir(path.dirname(pluginFile), { recursive: true });
+ await fs.writeFile(pluginFile, '// gitnexus', 'utf-8');
+
+ const uninstallCommand = await importUninstall();
+ await uninstallCommand();
+
+ expect(await fs.readFile(pluginFile, 'utf-8')).toBe('// gitnexus');
+ });
+
+ it('reports a non-ENOENT stat error on the plugin file instead of silently skipping', async () => {
+ const pluginFile = path.join(tempHome, '.config', 'opencode', 'plugins', 'gitnexus.js');
+ await fs.mkdir(path.dirname(pluginFile), { recursive: true });
+ await fs.writeFile(pluginFile, '// gitnexus', 'utf-8');
+
+ // Simulate EACCES on the plugin lookup only; every other call passes
+ // through. removeFile uses lstat (symlink-safe), so spy on that.
+ const realLstat = fs.lstat.bind(fs);
+ const statSpy = vi.spyOn(fs, 'lstat').mockImplementation((async (p: any, ...rest: any[]) => {
+ if (String(p).endsWith(path.join('plugins', 'gitnexus.js'))) {
+ const err: any = new Error(`EACCES: permission denied, stat '${p}'`);
+ err.code = 'EACCES';
+ throw err;
+ }
+ return (realLstat as any)(p, ...rest);
+ }) as any);
+
+ const logSpy = vi.spyOn(console, 'log');
+ const uninstallCommand = await importUninstall();
+ await uninstallCommand({ force: true });
+
+ // Before the fix, removeFile swallowed every stat error → plugin left in
+ // place with no error reported. Now EACCES surfaces in the Errors block.
+ const output = logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
+ expect(output).toContain('hooks plugin');
+ expect(statSpy).toHaveBeenCalled();
+ // The plugin file was never reached (rm), so it still exists.
+ expect(await fs.readFile(pluginFile, 'utf-8')).toBe('// gitnexus');
+ expect(process.exitCode).toBe(1);
+ });
+
+ it('removes a dangling plugin symlink instead of treating it as absent', async () => {
+ const pluginFile = path.join(tempHome, '.config', 'opencode', 'plugins', 'gitnexus.js');
+ const missingTarget = path.join(tempHome, 'does-not-exist.js');
+ await fs.mkdir(path.dirname(pluginFile), { recursive: true });
+ try {
+ await fs.symlink(missingTarget, pluginFile);
+ } catch (err: any) {
+ // Windows without developer mode / admin cannot create symlinks.
+ if (['EPERM', 'EOPNOTSUPP', 'UNKNOWN'].includes(err?.code)) return;
+ throw err;
+ }
+
+ const uninstallCommand = await importUninstall();
+ await uninstallCommand({ force: true });
+
+ // Before the fix, stat() followed the link, ENOENT'd, and the symlink
+ // survived at the auto-loaded plugins path.
+ await expect(fs.lstat(pluginFile)).rejects.toThrow();
+ });
+
// ── Antigravity MCP + hooks (AfterTool / gitnexus-antigravity-hook) ──
it('removes Antigravity MCP and AfterTool hooks plus the adapter script dir', async () => {
const mcpPath = path.join(tempHome, '.gemini', 'antigravity', 'mcp_config.json');