diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e608e85997..4fd6f8328d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -17,7 +17,23 @@ "Bash(cmd.exe /c \"cd /d D:\\\\Projects\\\\GitnexusV2 && gitnexus status\")", "Bash(gh repo clone:*)", "Bash(claude mcp:*)", - "Bash(gh issue view:*)" + "Bash(gh issue view:*)", + "Bash(echo:*)", + "Bash(node:*)", + "Bash(npm view:*)", + "Bash(npm version:*)", + "Bash(npm pack:*)", + "Bash(npm publish:*)", + "Bash(npx gitnexus:*)", + "mcp__gitnexus__list_repos", + "mcp__gitnexus__query", + "mcp__gitnexus__context", + "mcp__gitnexus__impact", + "Bash(git add:*)" ] - } + }, + "enableAllProjectMcpServers": true, + "enabledMcpjsonServers": [ + "gitnexus" + ] } diff --git a/.claude/worktrees/determined-hofstadter b/.claude/worktrees/determined-hofstadter new file mode 160000 index 0000000000..e90622aa24 --- /dev/null +++ b/.claude/worktrees/determined-hofstadter @@ -0,0 +1 @@ +Subproject commit e90622aa24c92d7a08712085d2355b9fe36cf3c3 diff --git a/.claude/worktrees/quirky-stonebraker b/.claude/worktrees/quirky-stonebraker new file mode 160000 index 0000000000..e90622aa24 --- /dev/null +++ b/.claude/worktrees/quirky-stonebraker @@ -0,0 +1 @@ +Subproject commit e90622aa24c92d7a08712085d2355b9fe36cf3c3 diff --git a/AGENTS.md b/AGENTS.md index 896c73f9dd..8bc8631769 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ # GitNexus MCP -This project is indexed by GitNexus as **GitnexusV2** (1023 symbols, 2504 relationships, 78 execution flows). +This project is indexed by GitNexus as **GitnexusV2** (1021 symbols, 2497 relationships, 77 execution flows). GitNexus provides a knowledge graph over this codebase — call chains, blast radius, execution flows, and semantic search. diff --git a/CLAUDE.md b/CLAUDE.md index e4266395d6..aef4e66516 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # GitNexus MCP -This project is indexed by GitNexus as **GitnexusV2** (1023 symbols, 2504 relationships, 78 execution flows). +This project is indexed by GitNexus as **GitnexusV2** (1021 symbols, 2497 relationships, 77 execution flows). GitNexus provides a knowledge graph over this codebase — call chains, blast radius, execution flows, and semantic search. diff --git a/gitnexus/_hook_test.json b/gitnexus/_hook_test.json new file mode 100644 index 0000000000..2337c7e110 --- /dev/null +++ b/gitnexus/_hook_test.json @@ -0,0 +1 @@ +{"hook_event_name":"PreToolUse","tool_name":"Grep","tool_input":{"pattern":"augment"},"cwd":"d:\\\\Projects\\\\GitnexusV2"} \ No newline at end of file diff --git a/gitnexus/hooks/claude/gitnexus-hook.js b/gitnexus/hooks/claude/gitnexus-hook.cjs similarity index 77% rename from gitnexus/hooks/claude/gitnexus-hook.js rename to gitnexus/hooks/claude/gitnexus-hook.cjs index 67c890ff5b..3b2e5f508e 100644 --- a/gitnexus/hooks/claude/gitnexus-hook.js +++ b/gitnexus/hooks/claude/gitnexus-hook.cjs @@ -101,11 +101,22 @@ function main() { const pattern = extractPattern(toolName, toolInput); if (!pattern || pattern.length < 3) return; - const result = execFileSync( - 'gitnexus', - ['augment', pattern], - { encoding: 'utf-8', timeout: 8000, cwd, stdio: ['pipe', 'pipe', 'pipe'] } - ); + // Resolve CLI path relative to this hook script (same package) + // hooks/claude/gitnexus-hook.cjs → dist/cli/index.js + const cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js'); + + // augment CLI writes result to stderr (KuzuDB's native module captures + // stdout fd at OS level, making it unusable in subprocess contexts). + const { spawnSync } = require('child_process'); + let result = ''; + try { + const child = spawnSync( + process.execPath, + [cliPath, 'augment', pattern], + { encoding: 'utf-8', timeout: 8000, cwd, stdio: ['pipe', 'pipe', 'pipe'] } + ); + result = child.stderr || ''; + } catch { /* graceful failure */ } if (result && result.trim()) { console.log(JSON.stringify({ @@ -115,8 +126,9 @@ function main() { } })); } - } catch { - // Graceful failure + } catch (err) { + // Graceful failure — log to stderr for debugging + console.error('GitNexus hook error:', err.message); } } diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index eab4823062..86415b4d9a 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitnexus", - "version": "1.1.8", + "version": "1.1.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitnexus", - "version": "1.1.8", + "version": "1.1.9", "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { "@huggingface/transformers": "^3.0.0", diff --git a/gitnexus/package.json b/gitnexus/package.json index 74c0c1ec05..ef7a2f4394 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -1,6 +1,6 @@ { "name": "gitnexus", - "version": "1.1.8", + "version": "1.1.9", "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.", "author": "Abhigyan Patwari", "license": "PolyForm-Noncommercial-1.0.0", diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index bd4554b539..26382632c2 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -14,6 +14,7 @@ import { getStoragePaths, saveMeta, loadMeta, addToGitignore, registerRepo, getG import { getCurrentCommit, isGitRepo, getGitRoot } from '../storage/git.js'; import { generateAIContextFiles } from './ai-context.js'; import fs from 'fs/promises'; +import { registerClaudeHook } from './claude-hooks.js'; export interface AnalyzeOptions { force?: boolean; @@ -137,6 +138,9 @@ export const analyzeCommand = async ( await addToGitignore(repoPath); + // Auto-register Claude Code hook (idempotent) + const hookResult = await registerClaudeHook(); + const projectName = path.basename(repoPath); let aggregatedClusterCount = 0; if (pipelineResult.communityResult?.communities) { @@ -172,6 +176,10 @@ export const analyzeCommand = async ( if (aiContext.files.length > 0) { console.log(` Context: ${aiContext.files.join(', ')}`); } + + if (hookResult.registered) { + console.log(` Hooks: ${hookResult.message}`); + } try { await fs.access(getGlobalRegistryPath()); diff --git a/gitnexus/src/cli/augment.ts b/gitnexus/src/cli/augment.ts index a3efbef165..b81d6feec8 100644 --- a/gitnexus/src/cli/augment.ts +++ b/gitnexus/src/cli/augment.ts @@ -15,7 +15,6 @@ import { augment } from '../core/augmentation/engine.js'; export async function augmentCommand(pattern: string): Promise { if (!pattern || pattern.length < 3) { - // Too short to be useful — exit silently process.exit(0); } @@ -23,7 +22,12 @@ export async function augmentCommand(pattern: string): Promise { const result = await augment(pattern, process.cwd()); if (result) { - process.stdout.write(result + '\n'); + // IMPORTANT: Write to stderr, NOT stdout. + // KuzuDB's native module captures stdout fd at OS level during init, + // which makes stdout permanently broken in subprocess contexts. + // stderr is never captured, so it works reliably everywhere. + // The hook reads from the subprocess's stderr. + process.stderr.write(result + '\n'); } } catch { // Graceful failure — never break the calling hook diff --git a/gitnexus/src/cli/claude-hooks.ts b/gitnexus/src/cli/claude-hooks.ts new file mode 100644 index 0000000000..c81bcc752a --- /dev/null +++ b/gitnexus/src/cli/claude-hooks.ts @@ -0,0 +1,111 @@ +/** + * Claude Code Hook Registration + * + * Registers the GitNexus PreToolUse hook in ~/.claude/hooks.json + * so that grep/glob/bash calls are automatically augmented with + * knowledge graph context. + * + * Idempotent — safe to call multiple times. + */ + +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * Get the absolute path to the gitnexus-hook.js file. + * Works for both local dev and npm-installed packages. + */ +function getHookScriptPath(): string { + // From dist/cli/claude-hooks.js → hooks/claude/gitnexus-hook.js + const packageRoot = path.resolve(__dirname, '..', '..'); + return path.join(packageRoot, 'hooks', 'claude', 'gitnexus-hook.cjs'); +} + +/** + * Register (or verify) the GitNexus hook in Claude Code's global hooks.json. + * + * - Creates ~/.claude/ and hooks.json if they don't exist + * - Preserves existing hooks from other tools + * - Skips if GitNexus hook is already registered + * + * Returns a status message for the CLI output. + */ +export async function registerClaudeHook(): Promise<{ registered: boolean; message: string }> { + const claudeDir = path.join(os.homedir(), '.claude'); + const hooksFile = path.join(claudeDir, 'hooks.json'); + const hookScript = getHookScriptPath(); + + // Check if the hook script exists + try { + await fs.access(hookScript); + } catch { + return { registered: false, message: 'Hook script not found (package may be incomplete)' }; + } + + // Build the hook command — use node + absolute path for reliability + const hookCommand = `node "${hookScript}"`; + + // Check if ~/.claude/ exists (user has Claude Code installed) + try { + await fs.access(claudeDir); + } catch { + // No Claude Code installation — skip silently + return { registered: false, message: 'Claude Code not detected (~/.claude/ not found)' }; + } + + // Read existing hooks.json or start fresh + let hooksConfig: any = {}; + try { + const existing = await fs.readFile(hooksFile, 'utf-8'); + hooksConfig = JSON.parse(existing); + } catch { + // File doesn't exist or is invalid — we'll create it + } + + // Ensure the hooks structure exists + if (!hooksConfig.hooks) { + hooksConfig.hooks = {}; + } + if (!Array.isArray(hooksConfig.hooks.PreToolUse)) { + hooksConfig.hooks.PreToolUse = []; + } + + // Check if GitNexus hook is already registered + const existingEntry = hooksConfig.hooks.PreToolUse.find((entry: any) => { + if (!entry.hooks || !Array.isArray(entry.hooks)) return false; + return entry.hooks.some((h: any) => + h.command && ( + h.command.includes('gitnexus-hook') || + h.command.includes('gitnexus augment') + ) + ); + }); + + if (existingEntry) { + return { registered: true, message: 'Claude Code hook already registered' }; + } + + // Add the GitNexus hook entry + hooksConfig.hooks.PreToolUse.push({ + matcher: { + tool_name: "Grep|Glob|Bash" + }, + hooks: [ + { + type: "command", + command: hookCommand, + timeout: 8000 + } + ] + }); + + // Write back + await fs.writeFile(hooksFile, JSON.stringify(hooksConfig, null, 2) + '\n', 'utf-8'); + + return { registered: true, message: 'Claude Code hook registered' }; +} diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index fd5703a1a6..becdd74e65 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -153,8 +153,8 @@ async function installClaudeCodeHooks(result: SetupResult): Promise { try { await fs.mkdir(destHooksDir, { recursive: true }); - const src = path.join(pluginHooksPath, 'gitnexus-hook.js'); - const dest = path.join(destHooksDir, 'gitnexus-hook.js'); + const src = path.join(pluginHooksPath, 'gitnexus-hook.cjs'); + const dest = path.join(destHooksDir, 'gitnexus-hook.cjs'); try { const content = await fs.readFile(src, 'utf-8'); await fs.writeFile(dest, content, 'utf-8'); @@ -162,7 +162,7 @@ async function installClaudeCodeHooks(result: SetupResult): Promise { // Script not found in source — skip } - const hookCmd = `node "${path.join(destHooksDir, 'gitnexus-hook.js').replace(/\\/g, '/')}"`; + const hookCmd = `node "${path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/')}"`; // Merge hook config into ~/.claude/settings.json const existing = await readJsonFile(settingsPath) || {}; @@ -182,7 +182,7 @@ async function installClaudeCodeHooks(result: SetupResult): Promise { hooks: [{ type: 'command', command: hookCmd, - timeout: 10, + timeout: 8000, statusMessage: 'Enriching with GitNexus graph context...', }], }); diff --git a/gitnexus/src/core/augmentation/engine.ts b/gitnexus/src/core/augmentation/engine.ts index 7960c34bf9..41ce54eb33 100644 --- a/gitnexus/src/core/augmentation/engine.ts +++ b/gitnexus/src/core/augmentation/engine.ts @@ -30,18 +30,44 @@ async function findRepoForCwd(cwd: string): Promise<{ const entries = await listRegisteredRepos({ validate: true }); const resolved = path.resolve(cwd); - // Find the repo whose path contains (or is) the cwd + // Normalize to lowercase on Windows (drive letters can differ: D: vs d:) + const isWindows = process.platform === 'win32'; + const normalizedCwd = isWindows ? resolved.toLowerCase() : resolved; + const sep = path.sep; + + // Find the LONGEST matching repo path (most specific match wins) + let bestMatch: typeof entries[0] | null = null; + let bestLen = 0; + for (const entry of entries) { const repoResolved = path.resolve(entry.path); - if (resolved.startsWith(repoResolved) || repoResolved.startsWith(resolved)) { - return { - name: entry.name, - storagePath: entry.storagePath, - kuzuPath: path.join(entry.storagePath, 'kuzu'), - }; + const normalizedRepo = isWindows ? repoResolved.toLowerCase() : repoResolved; + + // Check if cwd is inside repo OR repo is inside cwd + // Must match at a path separator boundary to avoid false positives + // (e.g. /projects/gitnexusv2 should NOT match /projects/gitnexus) + let matched = false; + if (normalizedCwd === normalizedRepo) { + matched = true; + } else if (normalizedCwd.startsWith(normalizedRepo + sep)) { + matched = true; + } else if (normalizedRepo.startsWith(normalizedCwd + sep)) { + matched = true; + } + + if (matched && normalizedRepo.length > bestLen) { + bestMatch = entry; + bestLen = normalizedRepo.length; } } - return null; + + if (!bestMatch) return null; + + return { + name: bestMatch.name, + storagePath: bestMatch.storagePath, + kuzuPath: path.join(bestMatch.storagePath, 'kuzu'), + }; } catch { return null; } diff --git a/gitnexus/src/core/search/bm25-index.ts b/gitnexus/src/core/search/bm25-index.ts index b71ab64214..d20ef1b801 100644 --- a/gitnexus/src/core/search/bm25-index.ts +++ b/gitnexus/src/core/search/bm25-index.ts @@ -62,24 +62,22 @@ export const searchFTSFromKuzu = async (query: string, limit: number = 20, repoI if (repoId) { // Use MCP connection pool via dynamic import + // IMPORTANT: KuzuDB uses a single connection per repo — queries must be sequential + // to avoid deadlocking. Do NOT use Promise.all here. const { executeQuery } = await import('../../mcp/core/kuzu-adapter.js'); const executor = (cypher: string) => executeQuery(repoId, cypher); - [fileResults, functionResults, classResults, methodResults, interfaceResults] = await Promise.all([ - queryFTSViaExecutor(executor, 'File', 'file_fts', query, limit), - queryFTSViaExecutor(executor, 'Function', 'function_fts', query, limit), - queryFTSViaExecutor(executor, 'Class', 'class_fts', query, limit), - queryFTSViaExecutor(executor, 'Method', 'method_fts', query, limit), - queryFTSViaExecutor(executor, 'Interface', 'interface_fts', query, limit), - ]); + fileResults = await queryFTSViaExecutor(executor, 'File', 'file_fts', query, limit); + functionResults = await queryFTSViaExecutor(executor, 'Function', 'function_fts', query, limit); + classResults = await queryFTSViaExecutor(executor, 'Class', 'class_fts', query, limit); + methodResults = await queryFTSViaExecutor(executor, 'Method', 'method_fts', query, limit); + interfaceResults = await queryFTSViaExecutor(executor, 'Interface', 'interface_fts', query, limit); } else { - // Use core kuzu adapter (CLI / pipeline context) - [fileResults, functionResults, classResults, methodResults, interfaceResults] = await Promise.all([ - queryFTS('File', 'file_fts', query, limit, false).catch(() => []), - queryFTS('Function', 'function_fts', query, limit, false).catch(() => []), - queryFTS('Class', 'class_fts', query, limit, false).catch(() => []), - queryFTS('Method', 'method_fts', query, limit, false).catch(() => []), - queryFTS('Interface', 'interface_fts', query, limit, false).catch(() => []), - ]); + // Use core kuzu adapter (CLI / pipeline context) — also sequential for safety + fileResults = await queryFTS('File', 'file_fts', query, limit, false).catch(() => []); + functionResults = await queryFTS('Function', 'function_fts', query, limit, false).catch(() => []); + classResults = await queryFTS('Class', 'class_fts', query, limit, false).catch(() => []); + methodResults = await queryFTS('Method', 'method_fts', query, limit, false).catch(() => []); + interfaceResults = await queryFTS('Interface', 'interface_fts', query, limit, false).catch(() => []); } // Merge results by filePath, summing scores for same file