feat(opencode): ship hooks as auto-loaded global plugin for full support (#3619)

* 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 <gergomagyar0@gmail.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
rgb-vgx 2026-10-28 20:45:08 +07:00 • committed by GitHub
parent 13d06d41c4
commit 338dee53ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1190 additions and 4 deletions

View file

@ -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.
<a id="fn-antigravity-hooks"></a>
> ¹ **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

View file

@ -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

View file

@ -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 <pattern>` 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 <pattern>` 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),
};
};

View file

@ -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).

View file

@ -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

View file

@ -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<void> {
}
}
/**
* 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<void> {
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')) {

View file

@ -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<boolean> {
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.

View file

@ -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',
);
});
});

View file

@ -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 () => {

View file

@ -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 <<EOF\ngit commit -m x\nEOF',
'git -C commit',
'git -c alias.ship=commit status',
'git --git-dir commit status',
'git --help commit',
'git --version commit',
'git -C "$OTHER_REPO" commit -m x',
])('does not check %s', async (command) => {
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();
});
});

View file

@ -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');