mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-26 06:02:22 +00:00
389 lines
14 KiB
JavaScript
389 lines
14 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 { spawnSync } 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');
|
|
|
|
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.
|
|
*/
|
|
export 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([
|
|
'-f',
|
|
'-m',
|
|
'-A',
|
|
'-B',
|
|
'-C',
|
|
'-g',
|
|
'--glob',
|
|
'-t',
|
|
'--type',
|
|
'--include',
|
|
'--exclude',
|
|
]);
|
|
|
|
/**
|
|
* 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].
|
|
* Quotes are consumed (they delimit, they are not part of the token).
|
|
*/
|
|
function tokenizeArgv(command) {
|
|
const tokens = [];
|
|
let current = '';
|
|
let quote = null;
|
|
for (const ch of command) {
|
|
if (quote) {
|
|
if (ch === quote) quote = null;
|
|
else current += ch;
|
|
continue;
|
|
}
|
|
if (ch === '"' || ch === "'") {
|
|
quote = ch;
|
|
continue;
|
|
}
|
|
if (/\s/.test(ch)) {
|
|
if (current) {
|
|
tokens.push(current);
|
|
current = '';
|
|
}
|
|
continue;
|
|
}
|
|
current += ch;
|
|
}
|
|
if (current) 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.
|
|
*/
|
|
export function extractBashSearchPattern(command) {
|
|
if (typeof command !== 'string' || !/\brg\b|\bgrep\b/.test(command)) return null;
|
|
const tokens = tokenizeArgv(command);
|
|
let foundCmd = false;
|
|
// 'skip' = drop the next token (flag value); 'pattern' = take it as the search.
|
|
let pending = null;
|
|
for (const token of tokens) {
|
|
if (pending === 'skip') {
|
|
pending = null;
|
|
continue;
|
|
}
|
|
if (pending === 'pattern') {
|
|
pending = null;
|
|
return token.length >= 3 ? token : null;
|
|
}
|
|
if (!foundCmd) {
|
|
if (/\brg$|\bgrep$/.test(token)) foundCmd = true;
|
|
continue;
|
|
}
|
|
if (token.startsWith('-')) {
|
|
if (PATTERN_FLAGS.has(token)) pending = 'pattern';
|
|
else if (FLAGS_WITH_VALUES.has(token)) pending = 'skip';
|
|
continue;
|
|
}
|
|
return token.length >= 3 ? token : null;
|
|
}
|
|
return 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).
|
|
*/
|
|
export 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;
|
|
}
|
|
|
|
/**
|
|
* True for the git commands that move HEAD (same set as the Claude adapter).
|
|
* Segments are checked after splitting on shell separators and each must
|
|
* START with `git`, so `echo 'git commit'` or `grep 'git commit' file` don't
|
|
* count as mutations while `cd x && git commit` still does.
|
|
*/
|
|
export function isGitMutationCommand(command) {
|
|
if (typeof command !== 'string') return false;
|
|
return command
|
|
.split(/&&|\|\||;|\|/)
|
|
.some((seg) => /^\s*git\s+(commit|merge|rebase|cherry-pick|pull)(\s|$)/.test(seg));
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
export function resolveCliInvocation() {
|
|
const fromEnv = process.env.GITNEXUS_HOOK_CLI_PATH;
|
|
if (fromEnv && String(fromEnv).trim()) {
|
|
try {
|
|
if (fs.existsSync(String(fromEnv))) {
|
|
return { cmd: process.execPath, argv: [String(fromEnv)], useShell: false };
|
|
}
|
|
} catch {
|
|
/* unreadable env path — fall through */
|
|
}
|
|
}
|
|
try {
|
|
if (typeof cliPath === 'string' && cliPath && fs.existsSync(cliPath)) {
|
|
return { cmd: process.execPath, 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. */
|
|
export 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.
|
|
*/
|
|
export function shouldSkipAugmentForShell(invocation, pattern) {
|
|
return Boolean(invocation && invocation.useShell && isShellMetaUnsafe(pattern));
|
|
}
|
|
|
|
/**
|
|
* Run `gitnexus augment <pattern>` in `cwd` and return the marked context,
|
|
* or '' on any failure (no repo, no index, lock contention, timeout).
|
|
*/
|
|
export 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 = spawnSync(invocation.cmd, [...invocation.argv, 'augment', '--', forwarded], {
|
|
encoding: 'utf-8',
|
|
timeout: 10000,
|
|
cwd,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
windowsHide: true,
|
|
...(invocation.useShell ? { shell: true } : {}),
|
|
});
|
|
if (child.error || child.status !== 0) return '';
|
|
return extractAugmentContext(child.stderr || '');
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse `gitnexus status --json` stdout. Returns the parsed payload or null.
|
|
*/
|
|
export 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.
|
|
*/
|
|
export function checkIndexStale(cwd) {
|
|
const invocation = resolveCliInvocation();
|
|
if (!invocation) return null;
|
|
try {
|
|
const child = spawnSync(invocation.cmd, [...invocation.argv, 'status', '--json'], {
|
|
encoding: 'utf-8',
|
|
timeout: 15000,
|
|
cwd,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
windowsHide: true,
|
|
...(invocation.useShell ? { shell: true } : {}),
|
|
});
|
|
if (child.error || child.status !== 0) return null;
|
|
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.
|
|
*/
|
|
export 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 = runAugment(pattern, cwd);
|
|
if (context) appendOutput(output, context);
|
|
}
|
|
if (tool === 'bash' && isGitMutationCommand(args.command)) {
|
|
const hint = checkIndexStale(cwd);
|
|
if (hint) appendOutput(output, hint);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
debugLog(`after-hook failed: ${(err && err.message) || err}`);
|
|
}
|
|
};
|
|
}
|
|
|
|
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),
|
|
};
|
|
};
|