Fix Claude Code hooks: correct settings path, matcher format, and hook script

- Hook config goes in ~/.claude/settings.json (not hooks.json)
- Matcher uses string format ("Grep|Glob|Bash") per new Claude Code schema
- Rename gitnexus-hook.js → gitnexus-hook.cjs for CommonJS compatibility
- Fix setup.ts: correct hook filename and timeout (8000ms instead of 10ms)
- Bump to v1.1.9 and publish to npm

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
abhigyanpatwari 2026-02-14 13:05:36 +05:30
parent dd53b33475
commit bcde561a0e
15 changed files with 221 additions and 43 deletions

View file

@ -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"
]
}

@ -0,0 +1 @@
Subproject commit e90622aa24c92d7a08712085d2355b9fe36cf3c3

@ -0,0 +1 @@
Subproject commit e90622aa24c92d7a08712085d2355b9fe36cf3c3

View file

@ -3,7 +3,7 @@
<!-- gitnexus:start -->
# 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.

View file

@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# 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.

1
gitnexus/_hook_test.json Normal file
View file

@ -0,0 +1 @@
{"hook_event_name":"PreToolUse","tool_name":"Grep","tool_input":{"pattern":"augment"},"cwd":"d:\\\\Projects\\\\GitnexusV2"}

View file

@ -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);
}
}

View file

@ -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",

View file

@ -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",

View file

@ -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());

View file

@ -15,7 +15,6 @@ import { augment } from '../core/augmentation/engine.js';
export async function augmentCommand(pattern: string): Promise<void> {
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<void> {
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

View file

@ -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' };
}

View file

@ -153,8 +153,8 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
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<void> {
// 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<void> {
hooks: [{
type: 'command',
command: hookCmd,
timeout: 10,
timeout: 8000,
statusMessage: 'Enriching with GitNexus graph context...',
}],
});

View file

@ -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;
}

View file

@ -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