GitNexus/gitnexus/hooks/opencode/gitnexus-plugin.js
rgb-vgx 338dee53ee
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>
2026-10-28 13:45:08 +00:00

543 lines
18 KiB
JavaScript

/**
* 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),
};
};