mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-29 06:32:28 +00:00
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:
parent
dd53b33475
commit
bcde561a0e
15 changed files with 221 additions and 43 deletions
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
1
.claude/worktrees/determined-hofstadter
Submodule
1
.claude/worktrees/determined-hofstadter
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit e90622aa24c92d7a08712085d2355b9fe36cf3c3
|
||||
1
.claude/worktrees/quirky-stonebraker
Submodule
1
.claude/worktrees/quirky-stonebraker
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit e90622aa24c92d7a08712085d2355b9fe36cf3c3
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
1
gitnexus/_hook_test.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"hook_event_name":"PreToolUse","tool_name":"Grep","tool_input":{"pattern":"augment"},"cwd":"d:\\\\Projects\\\\GitnexusV2"}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
4
gitnexus/package-lock.json
generated
4
gitnexus/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
111
gitnexus/src/cli/claude-hooks.ts
Normal file
111
gitnexus/src/cli/claude-hooks.ts
Normal 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' };
|
||||
}
|
||||
|
|
@ -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...',
|
||||
}],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue