GitNexus/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs
mengkaka 79543c8f83
feat(storage): add configurable index storage and content retention tiers (#3060)
* feat(storage): add configurable index storage and content retention tiers

Rebase #3060 onto current origin/main. Keep GITNEXUS_STORAGE_PATH,
GITNEXUS_STORAGE_ROOT, and GITNEXUS_CONTENT_RETENTION, and fold in
main's FTS skip, embed-session, and help-text updates.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Keep legacy registry rows on the local storage fallback, resolve
symlinks before the destructive-path guard, and align hook lookup
with CLI branch slugs, branch-slot metadata, and longest-path match.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Only list swept upload directories after a successful removal so
callers cannot treat a permission or transient rm failure as gone.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Document that getStoragePath may consult registered storage while
this module still does not mutate the global registry.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(storage): close review findings for external indexes and retention

Re-inspect ownership under the analyze lock, fail-closed when the
registry file is missing, and keep skip-git hook discovery plus
retention fields on HTTP/MCP list surfaces. /api/file stays 410
unless contentRetention is full.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* Address PR review feedback (#3060)

Treat lock-only index dirs as empty, honor HTTP --force storage policy, and prefer registered plus branch-aware slots in hooks and augment.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Keep hook fallbacks inside the current worktree, compare foreign-local slots canonically, and make storage fixtures survive ownership validation.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix macOS hook test expecting realpath'd registry paths.

resolveHookRepo returns the written registry path, not a filesystem realpath, so the assertion must match that.

* Address gitnexus-check warnings on hook install docs and slot tests.

The Cursor troubleshooting list omitted registry-query.cjs, and the writable-slot test only checked that isDirectory exists instead of that the path is a directory.

* Align the HTTP catalog source-scan with skippable resolveRepo validation.

resolveRepo lists fresh repos with validate: options.validateStorage !== false so DELETE can skip prune; the test still required a literal validate: true.

* Harden storage path sinks so CodeQL path-injection and ReDoS alerts clear.

Contain every filesystem probe inside the resolved storage slot with the inline path.relative idiom, reject filesystem-root slots, and trim slot basenames in linear time.

* Settle bridge stamps before writing so CI size/mtime matches stay stable.

LadybugDB can still flush into bridge.lbug after close+rename; persist whole-millisecond mtimes and wait for consecutive stats to agree so a freshly written pair matches.

* Type the settled bridge stat as fs.Stats so tsc does not see bigint.

Awaited<ReturnType<typeof fsp.stat>> collapsed the bigint overload and broke prepare/typecheck on CI.

* Keep the bridge mtime stamp exact so same-size swaps still fail the pair check.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Wrap the bridge stamp predicate so prettier --check stays green.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Require a quiet interval before stamping a settled bridge file.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Reuse shared storage and settle helpers instead of local copies.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-12 20:31:55 +00:00

430 lines
12 KiB
JavaScript

#!/usr/bin/env node
/**
* GitNexus Cursor postToolUse Hook
*
* Receives a JSON event on stdin describing a finished tool call, derives a
* search pattern (Grep query, Read file basename, or rg/grep arg from a Shell
* command), runs `gitnexus augment <pattern>`, and emits the enriched context
* back as `{ additional_context: "..." }` so the agent sees it alongside the
* tool result.
*
* Replaces the legacy beforeShellExecution / augment-shell.sh pipeline:
* - Cross-platform (no bash, no jq — runs on Windows out of the box)
* - Covers Read and Grep, not just Shell rg/grep
*
* Cursor 2.4+ generic hooks: https://cursor.com/docs/agent/hooks
*/
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { acquireHookSlot } = require('./hook-lock.cjs');
const { resolveHookRepo } = require('./registry-query.cjs');
function readInput() {
try {
const data = fs.readFileSync(0, 'utf-8');
return JSON.parse(data);
} catch {
return {};
}
}
function tokenizeShellWords(command) {
const tokens = [];
let current = '';
let quote = null;
let escaped = false;
let hasToken = false;
for (let index = 0; index < command.length; index += 1) {
const char = command[index];
if (escaped) {
current += char;
escaped = false;
hasToken = true;
continue;
}
if (quote === "'") {
if (char === "'") quote = null;
else current += char;
hasToken = true;
continue;
}
if (quote === '"') {
if (char === '"') {
quote = null;
} else if (char === '\\') {
const next = command[index + 1];
if (next === '$' || next === '`' || next === '"' || next === '\\') {
escaped = true;
} else {
current += '\\';
}
} else {
current += char;
}
hasToken = true;
continue;
}
if (char === '\\') {
const next = command[index + 1];
if (next === undefined || /\s/.test(next) || next === "'" || next === '"' || next === '\\') {
escaped = true;
} else {
current += '\\' + next;
index += 1;
}
hasToken = true;
} else if (char === "'" || char === '"') {
quote = char;
hasToken = true;
} else if (/\s/.test(char)) {
if (hasToken) tokens.push(current);
current = '';
hasToken = false;
} else if (char === ';' || char === '|' || char === '&') {
if (hasToken) tokens.push(current);
current = '';
hasToken = false;
const next = command[index + 1];
if ((char === '|' || char === '&') && next === char) {
tokens.push(char + char);
index += 1;
} else {
tokens.push(char);
}
} else {
current += char;
hasToken = true;
}
}
if (escaped) current += '\\';
if (hasToken) tokens.push(current);
return tokens;
}
function parseRgGrepPattern(cmd) {
const tokens = tokenizeShellWords(cmd);
let foundCmd = false;
let skipNext = false;
let skipNextAsPattern = false;
let endOfOptions = false;
let explicitPatternSeen = false;
let patternFileSeen = false;
const flagsWithValues = new Set([
'-e',
'-f',
'--file',
'-m',
'--max-count',
'-A',
'-B',
'-C',
'-g',
'--glob',
'--iglob',
'-t',
'--type',
'--include',
'--exclude',
'--encoding',
'--path',
]);
const rgValueFlags = new Set(['-r', '--replace']);
const patternFlags = new Set(['-e', '--regexp']);
const connectors = new Set(['&&', '||', ';', '|', '&']);
const wrappers = new Set([
'npx',
'bunx',
'pnpm',
'yarn',
'npm',
'sudo',
'env',
'command',
'time',
'nice',
'xargs',
'dlx',
'exec',
'run',
'git',
]);
const wrapperFlagsWithValues = new Set([
'--package',
'-p',
'--call',
'--prefix',
'--shell',
'--filter',
'--workspace',
'--dir',
'--cwd',
]);
const basename = (token) =>
token
.split(/[\\/]/)
.pop()
?.replace(/\.(exe|cmd|bat)$/i, '');
let previousToken;
let seenWrapper = false;
let searchCommand = null;
for (const token of tokens) {
if (skipNext) {
skipNext = false;
if (skipNextAsPattern) {
skipNextAsPattern = false;
if (token.length >= 3) return token;
}
previousToken = token;
continue;
}
if (!foundCmd) {
if (connectors.has(token)) {
seenWrapper = false;
previousToken = token;
continue;
}
const commandName = basename(token);
if (wrappers.has(commandName)) {
seenWrapper = true;
previousToken = token;
continue;
}
if (seenWrapper && token.startsWith('-')) {
const flagName = token.split('=', 1)[0];
if (!token.includes('=') && wrapperFlagsWithValues.has(flagName)) skipNext = true;
previousToken = token;
continue;
}
if (seenWrapper && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) {
previousToken = token;
continue;
}
const atCommandPosition =
previousToken === undefined ||
connectors.has(previousToken) ||
wrappers.has(basename(previousToken)) ||
seenWrapper;
if (atCommandPosition && (commandName === 'rg' || commandName === 'grep')) {
foundCmd = true;
searchCommand = commandName;
} else if (seenWrapper) {
seenWrapper = false;
}
previousToken = token;
continue;
}
previousToken = token;
if (endOfOptions) {
if (explicitPatternSeen || patternFileSeen) continue;
return token.length >= 3 ? token : null;
}
if (token === '--') {
endOfOptions = true;
continue;
}
if (token.startsWith('-')) {
if (token === '-f' || token === '--file') {
skipNext = true;
patternFileSeen = true;
continue;
}
if (token.startsWith('--file=')) {
patternFileSeen = true;
continue;
}
if (token.startsWith('--regexp=')) {
explicitPatternSeen = true;
const value = token.slice('--regexp='.length);
if (value.length >= 3) return value;
continue;
}
const attachedPattern = token.match(/^-e(.+)$/);
if (attachedPattern) {
explicitPatternSeen = true;
if (attachedPattern[1].length >= 3) return attachedPattern[1];
continue;
}
if (
flagsWithValues.has(token) ||
patternFlags.has(token) ||
(searchCommand === 'rg' && rgValueFlags.has(token))
) {
skipNext = true;
skipNextAsPattern = patternFlags.has(token);
if (skipNextAsPattern) explicitPatternSeen = true;
}
continue;
}
if (explicitPatternSeen || patternFileSeen) continue;
return token.length >= 3 ? token : null;
}
return null;
}
/**
* Extract a search pattern from the tool input. Cursor 2.4 docs at
* https://cursor.com/docs/agent/hooks list the tool *matchers* but do not
* formally specify the per-tool tool_input field names, so we probe a
* generous set of MCP-style aliases. As a last-resort fallback for Grep
* (the highest-frequency search path) we also accept the longest plausible
* string value in tool_input. Set GITNEXUS_DEBUG=1 to log the raw payload
* to stderr if Cursor changes the contract and aliases stop matching.
*/
function pickLongestStringValue(obj) {
let best = null;
if (!obj || typeof obj !== 'object') return null;
for (const v of Object.values(obj)) {
if (typeof v === 'string' && v.length >= 3 && (!best || v.length > best.length)) {
best = v;
}
}
return best;
}
function extractPattern(toolName, toolInput) {
const t = (toolName || '').toLowerCase();
if (t === 'grep') {
const aliases = [
toolInput.query,
toolInput.pattern,
toolInput.regex,
toolInput.q,
toolInput.search,
toolInput.searchQuery,
];
for (const a of aliases) {
if (typeof a === 'string' && a.length >= 3) return a;
}
// Last resort: scan tool_input for any reasonable-looking string value.
return pickLongestStringValue(toolInput);
}
if (t === 'read') {
const filePath =
toolInput.target_file ||
toolInput.file_path ||
toolInput.filePath ||
toolInput.path ||
toolInput.file ||
'';
if (!filePath) return null;
const base = path.basename(String(filePath), path.extname(String(filePath)));
const cleaned = base.replace(/[^a-zA-Z0-9_]/g, '');
return cleaned.length >= 3 ? cleaned : null;
}
if (t === 'shell') {
const cmd = toolInput.command || '';
if (!/\brg\b|\bgrep\b/.test(cmd)) return null;
return parseRgGrepPattern(cmd);
}
return null;
}
function resolveCliPath() {
try {
return require.resolve('gitnexus/dist/cli/index.js');
} catch {
return '';
}
}
function runGitNexusCli(cliPath, args, cwd, timeout) {
const isWin = process.platform === 'win32';
if (cliPath) {
return spawnSync(process.execPath, [cliPath, ...args], {
encoding: 'utf-8',
timeout,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
});
}
return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], {
encoding: 'utf-8',
timeout: timeout + 5000,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
});
}
function main() {
try {
const input = readInput();
if (process.env.GITNEXUS_DEBUG) {
// Echo the payload so users can capture Cursor's actual contract when
// diagnosing why augmentation isn't firing. Stderr only — stdout is
// reserved for the JSON response Cursor consumes.
try {
process.stderr.write(
`GitNexus Cursor hook stdin: ${JSON.stringify(input).slice(0, 500)}\n`,
);
} catch {
/* never let debug logging break the hook */
}
}
const cwd = input.cwd || process.cwd();
if (!path.isAbsolute(cwd)) return;
const toolName = input.tool_name || '';
const toolInput = input.tool_input || {};
const pattern = extractPattern(toolName, toolInput);
if (!pattern || pattern.length < 3) return;
// Registry row first (persisted external storagePath wins). Local owned
// `.gitnexus` is only the fallback when no matching registry row exists.
const repo = resolveHookRepo(cwd);
if (!repo) return;
const storagePath = repo.storagePath;
const release = acquireHookSlot(storagePath);
if (!release) {
// Normal skip path: all per-repo hook slots are held by concurrent
// sessions. Stays silent by default; surfaced only under the cursor
// hook's own GITNEXUS_DEBUG (truthy) convention. NOTE: unlike the
// claude/plugin/antigravity adapters this integration does not install
// hook-db-lock-probe.cjs, so its augment child is not guard-wrapped
// yet — tracked on the #2163 follow-up list ("cursor probe").
if (process.env.GITNEXUS_DEBUG) {
process.stderr.write('[GitNexus] augment skipped: hook slots saturated\n');
}
return;
}
const cliPath = resolveCliPath();
let result = '';
try {
const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
if (!child.error && child.status === 0) {
result = child.stderr || '';
}
} catch {
/* graceful failure */
} finally {
release();
}
if (result && result.trim()) {
console.log(JSON.stringify({ additional_context: result.trim() }));
}
} catch (err) {
if (process.env.GITNEXUS_DEBUG) {
console.error('GitNexus Cursor hook error:', (err.message || '').slice(0, 200));
}
}
}
if (require.main === module) main();
module.exports = { parseRgGrepPattern, tokenizeShellWords };