From 338dee53eec536980bd1052b3a4ec935f67c15f1 Mon Sep 17 00:00:00 2001 From: rgb-vgx Date: Wed, 28 Oct 2026 20:45:08 +0700 Subject: [PATCH] feat(opencode): ship hooks as auto-loaded global plugin for full support (#3619) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(opencode): ship hooks as auto-loaded global plugin for full support * fix(opencode): honor -e/--regexp as pattern and surface non-ENOENT plugin stat errors * fix(opencode): block shell-metachar patterns in Windows fallback and unlink dangling plugin symlinks * fix(opencode): quote-aware argv tokenization for quoted search phrases * fix(opencode): skip plugin write on literal drift, glob token extraction, segment-anchored mutation check * fix(opencode): parse shell search arguments safely * fix(opencode): check multiline git mutations * fix(opencode): recognize quoted git command boundaries * fix(opencode): skip ambiguous search options * docs(opencode): clarify compound search limits --------- Co-authored-by: Gergo Magyar Co-authored-by: Gergő Magyar --- docs/editor-setup.md | 12 +- gitnexus/README.md | 15 + gitnexus/hooks/opencode/gitnexus-plugin.js | 543 ++++++++++++++++++ gitnexus/scripts/cross-platform-tests.ts | 3 + gitnexus/src/cli/editor-targets.ts | 32 +- gitnexus/src/cli/setup.ts | 63 +- gitnexus/src/cli/uninstall.ts | 36 ++ .../test/integration/opencode-plugin.test.ts | 120 ++++ .../setup-uninstall-roundtrip.test.ts | 9 + gitnexus/test/unit/opencode-plugin.test.ts | 284 +++++++++ gitnexus/test/unit/uninstall.test.ts | 77 +++ 11 files changed, 1190 insertions(+), 4 deletions(-) create mode 100644 gitnexus/hooks/opencode/gitnexus-plugin.js create mode 100644 gitnexus/test/integration/opencode-plugin.test.ts create mode 100644 gitnexus/test/unit/opencode-plugin.test.ts 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');