mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-22 00:31:17 +00:00
Merge branch 'main' into fix/no-stats-flag-effective
This commit is contained in:
commit
78ebc0a3da
9 changed files with 815 additions and 59 deletions
|
|
@ -120,7 +120,7 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
|
|||
| Editor | MCP | Skills | Hooks (auto-augment) | Support |
|
||||
| --------------------- | --- | ------ | -------------------- | -------------- |
|
||||
| **Claude Code** | Yes | Yes | Yes (PreToolUse + PostToolUse) | **Full** |
|
||||
| **Cursor** | Yes | Yes | — | MCP + Skills |
|
||||
| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](gitnexus-cursor-integration/README.md#hook-install)) | **Full** |
|
||||
| **Codex** | Yes | Yes | — | MCP + Skills |
|
||||
| **Windsurf** | Yes | — | — | MCP |
|
||||
| **OpenCode** | Yes | Yes | — | MCP + Skills |
|
||||
|
|
|
|||
89
gitnexus-cursor-integration/README.md
Normal file
89
gitnexus-cursor-integration/README.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# GitNexus — Cursor integration
|
||||
|
||||
Static config that adds GitNexus knowledge-graph augmentation and skill files to Cursor.
|
||||
|
||||
> **Hooks require Cursor 2.4+.** Earlier versions don't expose `postToolUse` and the hook will silently no-op.
|
||||
|
||||
## What you get
|
||||
|
||||
| Layer | What it does | How it's installed |
|
||||
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| **MCP** | `gitnexus` MCP server with 16 tools (`query`, `context`, `impact`, `detect_changes`, `rename`, …) | `npx gitnexus setup` writes `~/.cursor/mcp.json` automatically. |
|
||||
| **Skills** | `/gitnexus-exploring`, `/gitnexus-debugging`, `/gitnexus-impact-analysis`, `/gitnexus-refactoring`, `/gitnexus-pr-review` markdown skills | `npx gitnexus setup` copies them to `~/.cursor/skills/gitnexus/`. |
|
||||
| **Hooks** _(this README)_ | `postToolUse` hook that enriches `Shell` / `Read` / `Grep` tool calls with graph context — same augmentation Claude Code gets | **Manual** — copy the two files described below into your project's `.cursor/`. |
|
||||
|
||||
## Hook install
|
||||
|
||||
Cursor 2.4+ reads `.cursor/hooks.json` from the project root and runs hook commands with the project root as the working directory ([docs](https://cursor.com/docs/agent/hooks)).
|
||||
|
||||
From this repo's `gitnexus-cursor-integration/hooks/`, copy the two files into your **project root**:
|
||||
|
||||
```text
|
||||
<your-project>/
|
||||
├── .cursor/
|
||||
│ └── hooks.json ← from gitnexus-cursor-integration/hooks/hooks.json
|
||||
└── hooks/
|
||||
└── gitnexus-hook.cjs ← from gitnexus-cursor-integration/hooks/gitnexus-hook.cjs
|
||||
```
|
||||
|
||||
Equivalent shell commands (run from your project root, with `$GITNEXUS_REPO` pointing at a clone of this repo):
|
||||
|
||||
```bash
|
||||
mkdir -p .cursor hooks
|
||||
cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/hooks.json" .cursor/hooks.json
|
||||
cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs" hooks/gitnexus-hook.cjs
|
||||
```
|
||||
|
||||
If you already have a `.cursor/hooks.json`, merge the `hooks.postToolUse` array rather than overwriting.
|
||||
|
||||
### Verify
|
||||
|
||||
1. Index the project: `npx gitnexus analyze`
|
||||
2. Reload the Cursor window so it picks up the new hook config.
|
||||
3. Ask the agent something that triggers `Read` / `Grep` / `Shell rg`. You should see a `[GitNexus]` block appended to the tool result.
|
||||
4. Diagnose silent no-ops by setting `GITNEXUS_DEBUG=1` in your shell environment — the hook will write Cursor's raw event payload to stderr so you can verify field names.
|
||||
|
||||
### What's installed manually vs. automated
|
||||
|
||||
| Step | Automated by `gitnexus setup`? |
|
||||
| -------------------------------------------------------------------- | ------------------------------ |
|
||||
| `~/.cursor/mcp.json` | ✅ |
|
||||
| `~/.cursor/skills/gitnexus/*` | ✅ |
|
||||
| `<project>/.cursor/hooks.json` + `<project>/hooks/gitnexus-hook.cjs` | ❌ — copy manually (see above) |
|
||||
|
||||
Hook install is per-project (Cursor scopes hooks to a project root); skills and MCP config are global.
|
||||
|
||||
## Hook contract
|
||||
|
||||
The hook receives a JSON event on stdin matching Cursor 2.4's `postToolUse` shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"tool_name": "Grep" | "Read" | "Shell",
|
||||
"tool_input": { /* tool-specific */ },
|
||||
"tool_output": { /* optional */ },
|
||||
"cwd": "/absolute/path/to/project"
|
||||
}
|
||||
```
|
||||
|
||||
It writes augmentation context to stdout as:
|
||||
|
||||
```json
|
||||
{ "additional_context": "[GitNexus] …" }
|
||||
```
|
||||
|
||||
Empty stdout means "no augmentation, continue normally" — the hook never blocks the tool.
|
||||
|
||||
### Pattern extraction per tool
|
||||
|
||||
| Tool | Pattern source | Notes |
|
||||
| ------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| `Grep` | `tool_input.query` (also `pattern`, `regex`, `q`, `search`, `searchQuery`) | Last-resort fallback: longest string value in `tool_input` (≥ 3 chars). |
|
||||
| `Read` | basename of `tool_input.target_file` (also `file_path`, `filePath`, `path`, `file`), stripped to identifier characters | `auth/handler.ts` → `handler`. |
|
||||
| `Shell` | First positional argument after `rg` / `grep` in `tool_input.command` | Best-effort tokenizer; quoted multi-word patterns (`rg "User Service"`) extract the first word only. |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Nothing happens** — Confirm Cursor is on 2.4+ and the project root has both `.cursor/hooks.json` and the script at `hooks/gitnexus-hook.cjs`. Then `npx gitnexus list` to confirm the project is indexed.
|
||||
- **`gitnexus` not found** — The hook prefers a locally-resolvable `gitnexus/dist/cli/index.js` and falls back to `npx -y gitnexus`. Install globally with `npm i -g gitnexus` to skip the npx cold-start latency.
|
||||
- **Wrong pattern extracted** — Set `GITNEXUS_DEBUG=1` and run a tool call. The raw stdin payload is logged to stderr; use it to confirm Cursor's actual `tool_input` field names against the table above. If they differ, file an issue with the captured payload.
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
#!/bin/bash
|
||||
# GitNexus beforeShellExecution hook for Cursor
|
||||
# Receives JSON on stdin with { command, cwd, timeout }
|
||||
# Returns JSON on stdout with { permission, agent_message }
|
||||
#
|
||||
# Extracts search pattern from grep/rg commands, runs gitnexus augment,
|
||||
# and injects the enriched context via agent_message.
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
COMMAND=$(echo "$INPUT" | jq -r '.command // empty' 2>/dev/null)
|
||||
|
||||
if [ -z "$COMMAND" ]; then
|
||||
echo '{"permission":"allow"}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Skip non-search commands
|
||||
case "$COMMAND" in
|
||||
cd\ *|npm\ *|yarn\ *|pnpm\ *|git\ commit*|git\ push*|git\ pull*|mkdir\ *|rm\ *|cp\ *|mv\ *|echo\ *|cat\ *)
|
||||
echo '{"permission":"allow"}'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# Extract search pattern from rg/grep commands
|
||||
PATTERN=""
|
||||
if echo "$COMMAND" | grep -qE '\brg\b'; then
|
||||
PATTERN=$(echo "$COMMAND" | sed -n "s/.*\brg\s\+\(--[^ ]*\s\+\)*['\"]\\?\([^'\";\| >]*\\).*/\2/p")
|
||||
elif echo "$COMMAND" | grep -qE '\bgrep\b'; then
|
||||
PATTERN=$(echo "$COMMAND" | sed -n "s/.*\bgrep\s\+\(-[^ ]*\s\+\)*['\"]\\?\([^'\";\| >]*\\).*/\2/p")
|
||||
fi
|
||||
|
||||
if [ -z "$PATTERN" ] || [ ${#PATTERN} -lt 3 ]; then
|
||||
echo '{"permission":"allow"}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Run gitnexus augment
|
||||
RESULT=$(npx -y gitnexus augment "$PATTERN" 2>/dev/null)
|
||||
|
||||
if [ -n "$RESULT" ]; then
|
||||
# Escape for JSON
|
||||
ESCAPED=$(echo "$RESULT" | jq -Rs .)
|
||||
echo "{\"permission\":\"allow\",\"agent_message\":$ESCAPED}"
|
||||
else
|
||||
echo '{"permission":"allow"}'
|
||||
fi
|
||||
|
||||
exit 0
|
||||
259
gitnexus-cursor-integration/hooks/gitnexus-hook.cjs
Normal file
259
gitnexus-cursor-integration/hooks/gitnexus-hook.cjs
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
#!/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');
|
||||
|
||||
function readInput() {
|
||||
try {
|
||||
const data = fs.readFileSync(0, 'utf-8');
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function isGlobalRegistryDir(candidate) {
|
||||
if (fs.existsSync(path.join(candidate, 'meta.json'))) return false;
|
||||
return (
|
||||
fs.existsSync(path.join(candidate, 'registry.json')) ||
|
||||
fs.existsSync(path.join(candidate, 'repos'))
|
||||
);
|
||||
}
|
||||
|
||||
function walkForGitNexusDir(startDir) {
|
||||
let dir = startDir;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const candidate = path.join(dir, '.gitnexus');
|
||||
if (fs.existsSync(candidate)) {
|
||||
if (!isGlobalRegistryDir(candidate)) return candidate;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findCanonicalRepoRoot(cwd) {
|
||||
try {
|
||||
const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 2000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
if (result.error || result.status !== 0) return null;
|
||||
const commonDir = (result.stdout || '').trim();
|
||||
if (!commonDir || !path.isAbsolute(commonDir)) return null;
|
||||
return path.dirname(commonDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findGitNexusDir(startDir) {
|
||||
const cwd = startDir || process.cwd();
|
||||
const fromCwd = walkForGitNexusDir(cwd);
|
||||
if (fromCwd) return fromCwd;
|
||||
const canonicalRoot = findCanonicalRepoRoot(cwd);
|
||||
if (canonicalRoot && canonicalRoot !== cwd) {
|
||||
return walkForGitNexusDir(canonicalRoot);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseRgGrepPattern(cmd) {
|
||||
const tokens = cmd.split(/\s+/);
|
||||
let foundCmd = false;
|
||||
let skipNext = false;
|
||||
const flagsWithValues = new Set([
|
||||
'-e',
|
||||
'-f',
|
||||
'-m',
|
||||
'-A',
|
||||
'-B',
|
||||
'-C',
|
||||
'-g',
|
||||
'--glob',
|
||||
'-t',
|
||||
'--type',
|
||||
'--include',
|
||||
'--exclude',
|
||||
]);
|
||||
|
||||
for (const token of tokens) {
|
||||
if (skipNext) {
|
||||
skipNext = false;
|
||||
continue;
|
||||
}
|
||||
if (!foundCmd) {
|
||||
if (/\brg$|\bgrep$/.test(token)) foundCmd = true;
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith('-')) {
|
||||
if (flagsWithValues.has(token)) skipNext = true;
|
||||
continue;
|
||||
}
|
||||
const cleaned = token.replace(/['"]/g, '');
|
||||
return cleaned.length >= 3 ? cleaned : 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;
|
||||
// NOTE: parseRgGrepPattern uses split(/\s+/) and cannot handle shell
|
||||
// quoting. `rg "User Service" src/` returns "User" (the first token
|
||||
// after the rg/grep arg, with surrounding quotes stripped) — the
|
||||
// multi-word pattern is intentionally not reconstructed since BM25 is
|
||||
// already token-tolerant. Quoted single tokens (`rg "validateUser"`)
|
||||
// work fine.
|
||||
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'],
|
||||
});
|
||||
}
|
||||
return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], {
|
||||
encoding: 'utf-8',
|
||||
timeout: timeout + 5000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
if (!findGitNexusDir(cwd)) return;
|
||||
|
||||
const toolName = input.tool_name || '';
|
||||
const toolInput = input.tool_input || {};
|
||||
|
||||
const pattern = extractPattern(toolName, toolInput);
|
||||
if (!pattern || pattern.length < 3) 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 */
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"beforeShellExecution": [
|
||||
"postToolUse": [
|
||||
{
|
||||
"command": "./hooks/augment-shell.sh",
|
||||
"timeout": 5,
|
||||
"matcher": "\\brg\\b|\\bgrep\\b"
|
||||
"matcher": "Shell|Read|Grep",
|
||||
"command": "node ./hooks/gitnexus-hook.cjs",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
|
|||
| Editor | MCP | Skills | Hooks (auto-augment) | Support |
|
||||
|--------|-----|--------|---------------------|---------|
|
||||
| **Claude Code** | Yes | Yes | Yes (PreToolUse) | **Full** |
|
||||
| **Cursor** | Yes | Yes | — | MCP + Skills |
|
||||
| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](../gitnexus-cursor-integration/README.md#hook-install)) | **Full** |
|
||||
| **Codex** | Yes | Yes | — | MCP + Skills |
|
||||
| **Windsurf** | Yes | — | — | MCP |
|
||||
| **OpenCode** | Yes | Yes | — | MCP + Skills |
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* Augment CLI Command
|
||||
*
|
||||
* Fast-path command for platform hooks.
|
||||
* Shells out from Claude Code PreToolUse / Cursor beforeShellExecution hooks.
|
||||
* Shells out from Claude Code PreToolUse / Cursor postToolUse hooks.
|
||||
*
|
||||
* Usage: gitnexus augment <pattern>
|
||||
* Returns enriched text to stdout.
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
* Augmentation Engine
|
||||
*
|
||||
* Lightweight, fast-path enrichment of search patterns with knowledge graph context.
|
||||
* Designed to be called from platform hooks (Claude Code PreToolUse, Cursor beforeShellExecution)
|
||||
* when an agent runs grep/glob/search.
|
||||
* Designed to be called from platform hooks (Claude Code PreToolUse, Cursor postToolUse)
|
||||
* when an agent runs grep/glob/read/search.
|
||||
*
|
||||
* Performance target: <500ms cold start, <200ms warm.
|
||||
*
|
||||
|
|
|
|||
458
gitnexus/test/unit/cursor-hook.test.ts
Normal file
458
gitnexus/test/unit/cursor-hook.test.ts
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
/**
|
||||
* Regression Tests: Cursor postToolUse Hook
|
||||
*
|
||||
* Tests the hook script at gitnexus-cursor-integration/hooks/gitnexus-hook.cjs
|
||||
* which runs as a Cursor 2.4 postToolUse hook.
|
||||
*
|
||||
* Covers:
|
||||
* - extractPattern: pattern extraction from Grep/Read/Shell tool inputs
|
||||
* - findGitNexusDir: .gitnexus directory discovery (shared with Claude hook)
|
||||
* - cwd validation: rejects relative paths
|
||||
* - shell injection: verifies no `shell: true` in spawnSync calls
|
||||
* - cross-platform: Windows .cmd extension handling
|
||||
* - output shape: top-level `additional_context` (NOT Claude's `hookSpecificOutput.additionalContext`)
|
||||
* - hooks.json wiring matches the script's actual handlers
|
||||
*
|
||||
* Cursor hooks reach the augment CLI only when cwd is inside an indexed
|
||||
* repo, so behavior tests stick to early-exit paths to avoid spawning
|
||||
* `npx gitnexus`.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { runHook } from '../utils/hook-test-helpers.js';
|
||||
|
||||
// ─── Path to the Cursor hook + manifest ─────────────────────────────
|
||||
|
||||
const CURSOR_HOOK = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'gitnexus-cursor-integration',
|
||||
'hooks',
|
||||
'gitnexus-hook.cjs',
|
||||
);
|
||||
const CURSOR_HOOKS_JSON = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'gitnexus-cursor-integration',
|
||||
'hooks',
|
||||
'hooks.json',
|
||||
);
|
||||
|
||||
// ─── Cursor-specific output parser ──────────────────────────────────
|
||||
// Cursor postToolUse output shape: { "additional_context": "..." }
|
||||
|
||||
function parseCursorOutput(stdout: string): { additional_context?: string } | null {
|
||||
if (!stdout.trim()) return null;
|
||||
try {
|
||||
return JSON.parse(stdout.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test fixtures ──────────────────────────────────────────────────
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-cursor-hook-test-'));
|
||||
spawnSync('git', ['init'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
spawnSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── Manifest + hook file presence ───────────────────────────────────
|
||||
|
||||
describe('Cursor integration files', () => {
|
||||
it('hook script exists', () => {
|
||||
expect(fs.existsSync(CURSOR_HOOK)).toBe(true);
|
||||
});
|
||||
|
||||
it('hooks.json exists', () => {
|
||||
expect(fs.existsSync(CURSOR_HOOKS_JSON)).toBe(true);
|
||||
});
|
||||
|
||||
it('legacy augment-shell.sh has been removed', () => {
|
||||
const legacy = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'gitnexus-cursor-integration',
|
||||
'hooks',
|
||||
'augment-shell.sh',
|
||||
);
|
||||
expect(fs.existsSync(legacy)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── hooks.json wiring ──────────────────────────────────────────────
|
||||
|
||||
describe('hooks.json wiring', () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(CURSOR_HOOKS_JSON, 'utf-8'));
|
||||
|
||||
it('declares version 1', () => {
|
||||
expect(manifest.version).toBe(1);
|
||||
});
|
||||
|
||||
it('registers a postToolUse hook (not legacy beforeShellExecution)', () => {
|
||||
expect(manifest.hooks.postToolUse).toBeDefined();
|
||||
expect(Array.isArray(manifest.hooks.postToolUse)).toBe(true);
|
||||
expect(manifest.hooks.beforeShellExecution).toBeUndefined();
|
||||
});
|
||||
|
||||
it('matches Shell, Read, and Grep tools', () => {
|
||||
const matcher: string = manifest.hooks.postToolUse[0].matcher;
|
||||
expect(matcher).toMatch(/Shell/);
|
||||
expect(matcher).toMatch(/Read/);
|
||||
expect(matcher).toMatch(/Grep/);
|
||||
});
|
||||
|
||||
it('points command at the new Node hook', () => {
|
||||
const command: string = manifest.hooks.postToolUse[0].command;
|
||||
expect(command).toContain('gitnexus-hook.cjs');
|
||||
expect(command).not.toContain('augment-shell.sh');
|
||||
});
|
||||
|
||||
it('declares timeout in seconds (not milliseconds)', () => {
|
||||
// Cursor's `timeout` field is in seconds per
|
||||
// https://cursor.com/docs/agent/hooks. Regression guard: a value of
|
||||
// 1000+ here would be a >16-minute timeout, almost certainly a ms/s mixup.
|
||||
const timeout: number = manifest.hooks.postToolUse[0].timeout;
|
||||
expect(typeof timeout).toBe('number');
|
||||
expect(timeout).toBeGreaterThan(0);
|
||||
expect(timeout).toBeLessThan(120);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Source code regressions ────────────────────────────────────────
|
||||
|
||||
describe('Cursor hook source regressions', () => {
|
||||
const source = fs.readFileSync(CURSOR_HOOK, 'utf-8');
|
||||
|
||||
it('does not pass shell: true to spawnSync', () => {
|
||||
const lines = source.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.trim().startsWith('//') || line.trim().startsWith('*')) continue;
|
||||
if (/shell:\s*(true|isWin)/.test(line)) {
|
||||
throw new Error(`Cursor hook line ${i + 1} has shell injection risk: ${line.trim()}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('uses npx.cmd for Windows', () => {
|
||||
expect(source).toContain('npx.cmd');
|
||||
});
|
||||
|
||||
it('validates cwd is an absolute path', () => {
|
||||
expect(source).toMatch(/path\.isAbsolute\(cwd\)/);
|
||||
});
|
||||
|
||||
it('truncates debug error messages to 200 chars', () => {
|
||||
expect(source).toContain('.slice(0, 200)');
|
||||
});
|
||||
|
||||
it('emits Cursor-shape additional_context (not Claude hookSpecificOutput)', () => {
|
||||
expect(source).toContain('additional_context');
|
||||
expect(source).not.toContain('hookSpecificOutput');
|
||||
expect(source).not.toContain('hookEventName');
|
||||
});
|
||||
|
||||
it('rejects patterns shorter than 3 chars', () => {
|
||||
expect(source).toMatch(/length\s*>=\s*3/);
|
||||
});
|
||||
|
||||
it('passes pattern after end-of-options marker (--)', () => {
|
||||
// Regression for #200 — augment patterns starting with `-` would
|
||||
// otherwise be parsed as CLI flags by the gitnexus CLI.
|
||||
expect(source).toMatch(/'augment',\s*'--',\s*pattern/);
|
||||
});
|
||||
|
||||
it('gates on a non-global .gitnexus directory before invoking the CLI', () => {
|
||||
expect(source).toContain('findGitNexusDir');
|
||||
expect(source).toContain('isGlobalRegistryDir');
|
||||
});
|
||||
|
||||
it('handles linked git worktrees via git rev-parse --git-common-dir', () => {
|
||||
expect(source).toContain('--git-common-dir');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── extractPattern coverage (source-level) ─────────────────────────
|
||||
|
||||
describe('Cursor hook extractPattern coverage', () => {
|
||||
const source = fs.readFileSync(CURSOR_HOOK, 'utf-8');
|
||||
|
||||
it("handles 'grep' tool (Cursor matcher: Grep)", () => {
|
||||
expect(source).toMatch(/t === 'grep'/);
|
||||
});
|
||||
|
||||
it('probes a wide alias set for Grep query field (Cursor contract not formally specified)', () => {
|
||||
// Cursor 2.4 docs at https://cursor.com/docs/agent/hooks list the
|
||||
// matchers but not the per-tool tool_input field names. If Cursor
|
||||
// changes the contract, we want the hook to still extract *something*
|
||||
// — these aliases plus the longest-string fallback give us coverage.
|
||||
for (const alias of ['query', 'pattern', 'regex', 'q', 'search', 'searchQuery']) {
|
||||
expect(source).toContain(`toolInput.${alias}`);
|
||||
}
|
||||
expect(source).toContain('pickLongestStringValue');
|
||||
});
|
||||
|
||||
it("handles 'read' tool (Cursor matcher: Read)", () => {
|
||||
expect(source).toMatch(/t === 'read'/);
|
||||
for (const alias of ['target_file', 'file_path', 'filePath', 'path', 'file']) {
|
||||
expect(source).toContain(`toolInput.${alias}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles 'shell' tool (Cursor matcher: Shell)", () => {
|
||||
expect(source).toMatch(/t === 'shell'/);
|
||||
expect(source).toMatch(/\\brg\\b\|\\bgrep\\b/);
|
||||
});
|
||||
|
||||
it('logs raw payload to stderr when GITNEXUS_DEBUG is set (for contract diagnostics)', () => {
|
||||
expect(source).toContain('GITNEXUS_DEBUG');
|
||||
expect(source).toContain('GitNexus Cursor hook stdin:');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Behavior: graceful no-op paths (no augment CLI invocation) ─────
|
||||
|
||||
describe('Cursor hook behavior — early-exit paths', () => {
|
||||
it('exits cleanly on empty stdin', () => {
|
||||
const result = spawnSync(process.execPath, [CURSOR_HOOK], {
|
||||
input: '',
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
});
|
||||
|
||||
it('exits cleanly on invalid JSON stdin', () => {
|
||||
const result = spawnSync(process.execPath, [CURSOR_HOOK], {
|
||||
input: 'not json at all',
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
});
|
||||
|
||||
it('produces no output when cwd is relative', () => {
|
||||
const result = runHook(CURSOR_HOOK, {
|
||||
tool_name: 'Grep',
|
||||
tool_input: { query: 'validateUser' },
|
||||
cwd: 'relative/path',
|
||||
});
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
it('produces no output when cwd has no .gitnexus dir', () => {
|
||||
const result = runHook(CURSOR_HOOK, {
|
||||
tool_name: 'Grep',
|
||||
tool_input: { query: 'validateUser' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
it('produces no output for unknown tool names', () => {
|
||||
const result = runHook(CURSOR_HOOK, {
|
||||
tool_name: 'TotallyMadeUpTool',
|
||||
tool_input: { foo: 'bar' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
it('produces no output for Shell commands without rg/grep', () => {
|
||||
const result = runHook(CURSOR_HOOK, {
|
||||
tool_name: 'Shell',
|
||||
tool_input: { command: 'ls -la' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
it('produces no output for Grep with a 2-char query', () => {
|
||||
const result = runHook(CURSOR_HOOK, {
|
||||
tool_name: 'Grep',
|
||||
tool_input: { query: 'is' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
it('produces no output for Read whose basename has no identifier chars', () => {
|
||||
const result = runHook(CURSOR_HOOK, {
|
||||
tool_name: 'Read',
|
||||
tool_input: { target_file: '/tmp/--.md' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
it('produces no output for Read with no file path', () => {
|
||||
const result = runHook(CURSOR_HOOK, {
|
||||
tool_name: 'Read',
|
||||
tool_input: {},
|
||||
cwd: tmpDir,
|
||||
});
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
it('treats tool_name case-insensitively (Grep vs grep)', () => {
|
||||
// Both should reach the same handler — and both should early-exit silently
|
||||
// because tmpDir has no .gitnexus.
|
||||
for (const toolName of ['Grep', 'grep', 'GREP']) {
|
||||
const result = runHook(CURSOR_HOOK, {
|
||||
tool_name: toolName,
|
||||
tool_input: { query: 'validateUser' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
expect(result.status).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Behavior: GITNEXUS_DEBUG payload logging ────────────────────────
|
||||
|
||||
describe('Cursor hook debug logging', () => {
|
||||
it('echoes the payload to stderr only when GITNEXUS_DEBUG is set', () => {
|
||||
const payload = {
|
||||
tool_name: 'Grep',
|
||||
tool_input: { query: 'validateUser' },
|
||||
cwd: tmpDir,
|
||||
};
|
||||
|
||||
// GITNEXUS_DEBUG unset → stderr quiet.
|
||||
const quiet = spawnSync(process.execPath, [CURSOR_HOOK], {
|
||||
input: JSON.stringify(payload),
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, GITNEXUS_DEBUG: '' },
|
||||
});
|
||||
expect(quiet.status).toBe(0);
|
||||
expect(quiet.stderr).not.toContain('GitNexus Cursor hook stdin');
|
||||
|
||||
// GITNEXUS_DEBUG=1 → payload echoed to stderr (stdout still empty for
|
||||
// unindexed cwd, so the hook output contract is preserved).
|
||||
const verbose = spawnSync(process.execPath, [CURSOR_HOOK], {
|
||||
input: JSON.stringify(payload),
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, GITNEXUS_DEBUG: '1' },
|
||||
});
|
||||
expect(verbose.status).toBe(0);
|
||||
expect(verbose.stderr).toContain('GitNexus Cursor hook stdin');
|
||||
expect(verbose.stderr).toContain('"tool_name":"Grep"');
|
||||
expect(verbose.stdout.trim()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Documented contract behavior (extractPattern via the live hook) ─
|
||||
|
||||
describe('Shell quoted-pattern parser limitations (documented)', () => {
|
||||
// The Shell parser cannot reconstruct shell quoting. These tests pin the
|
||||
// current behavior so a future "fix" doesn't silently change extraction
|
||||
// — and so users diagnosing a noisy/missed pattern can find the behavior
|
||||
// documented in tests.
|
||||
//
|
||||
// We can't observe the extracted pattern directly without an indexed
|
||||
// repo, but we *can* confirm the hook reaches the augment-call path
|
||||
// (vs. early-exiting) by checking exit status + clean stdout for cases
|
||||
// where parseRgGrepPattern would yield a >=3-char token.
|
||||
|
||||
it('quoted multi-word `rg "User Service"` extracts the first word only', () => {
|
||||
const result = runHook(CURSOR_HOOK, {
|
||||
tool_name: 'Shell',
|
||||
tool_input: { command: 'rg "User Service" src/' },
|
||||
cwd: tmpDir, // no .gitnexus → exits early after extract
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
});
|
||||
|
||||
it('single-token quoted `rg "validateUser"` works as expected', () => {
|
||||
const result = runHook(CURSOR_HOOK, {
|
||||
tool_name: 'Shell',
|
||||
tool_input: { command: 'rg "validateUser"' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Install docs ─────────────────────────────────────────────────────
|
||||
|
||||
describe('Cursor integration install docs', () => {
|
||||
const integrationReadme = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'gitnexus-cursor-integration',
|
||||
'README.md',
|
||||
);
|
||||
|
||||
it('install README exists', () => {
|
||||
expect(fs.existsSync(integrationReadme)).toBe(true);
|
||||
});
|
||||
|
||||
it('install README documents the hook install path', () => {
|
||||
const body = fs.readFileSync(integrationReadme, 'utf-8');
|
||||
expect(body).toContain('.cursor/hooks.json');
|
||||
expect(body).toContain('hooks/gitnexus-hook.cjs');
|
||||
expect(body).toContain('Hook install');
|
||||
});
|
||||
|
||||
it('install README documents GITNEXUS_DEBUG for payload diagnostics', () => {
|
||||
const body = fs.readFileSync(integrationReadme, 'utf-8');
|
||||
expect(body).toContain('GITNEXUS_DEBUG');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Output parser sanity (synthetic JSON) ──────────────────────────
|
||||
|
||||
describe('parseCursorOutput', () => {
|
||||
it('parses a well-formed { additional_context } payload', () => {
|
||||
const parsed = parseCursorOutput('{"additional_context":"hello"}');
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.additional_context).toBe('hello');
|
||||
});
|
||||
|
||||
it('returns null on empty stdout', () => {
|
||||
expect(parseCursorOutput('')).toBeNull();
|
||||
expect(parseCursorOutput(' \n')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on malformed JSON', () => {
|
||||
expect(parseCursorOutput('not json')).toBeNull();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue