diff --git a/README.md b/README.md index 6beadb63d..3c3a28c27 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/gitnexus-cursor-integration/README.md b/gitnexus-cursor-integration/README.md new file mode 100644 index 000000000..0da8b1981 --- /dev/null +++ b/gitnexus-cursor-integration/README.md @@ -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 +/ +├── .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/*` | ✅ | +| `/.cursor/hooks.json` + `/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. diff --git a/gitnexus-cursor-integration/hooks/augment-shell.sh b/gitnexus-cursor-integration/hooks/augment-shell.sh deleted file mode 100644 index 48ea185b0..000000000 --- a/gitnexus-cursor-integration/hooks/augment-shell.sh +++ /dev/null @@ -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 diff --git a/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs b/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs new file mode 100644 index 000000000..0ea336619 --- /dev/null +++ b/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs @@ -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 `, 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(); diff --git a/gitnexus-cursor-integration/hooks/hooks.json b/gitnexus-cursor-integration/hooks/hooks.json index ede0dfbf3..9ae542c14 100644 --- a/gitnexus-cursor-integration/hooks/hooks.json +++ b/gitnexus-cursor-integration/hooks/hooks.json @@ -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 } ] } diff --git a/gitnexus/README.md b/gitnexus/README.md index 8a5144598..55cdc12f7 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -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 | diff --git a/gitnexus/src/cli/augment.ts b/gitnexus/src/cli/augment.ts index 553e3f591..a97f6e23c 100644 --- a/gitnexus/src/cli/augment.ts +++ b/gitnexus/src/cli/augment.ts @@ -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 * Returns enriched text to stdout. diff --git a/gitnexus/src/core/augmentation/engine.ts b/gitnexus/src/core/augmentation/engine.ts index f97415cc9..42087e4b0 100644 --- a/gitnexus/src/core/augmentation/engine.ts +++ b/gitnexus/src/core/augmentation/engine.ts @@ -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. * diff --git a/gitnexus/test/unit/cursor-hook.test.ts b/gitnexus/test/unit/cursor-hook.test.ts new file mode 100644 index 000000000..f0d875dee --- /dev/null +++ b/gitnexus/test/unit/cursor-hook.test.ts @@ -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(); + }); +});