mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-11-11 08:37:35 +00:00
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:
parent
13d06d41c4
commit
338dee53ee
11 changed files with 1190 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
543
gitnexus/hooks/opencode/gitnexus-plugin.js
Normal file
543
gitnexus/hooks/opencode/gitnexus-plugin.js
Normal 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),
|
||||
};
|
||||
};
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
120
gitnexus/test/integration/opencode-plugin.test.ts
Normal file
120
gitnexus/test/integration/opencode-plugin.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
284
gitnexus/test/unit/opencode-plugin.test.ts
Normal file
284
gitnexus/test/unit/opencode-plugin.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue