Sfoglia il codice sorgente

Merge pull request #22 from colbymchenry/claude-hooks

Add Claude Code hooks for automatic CodeGraph sync
Colby Mchenry 4 mesi fa
parent
commit
c034c50689
4 ha cambiato i file con 175 aggiunte e 4 eliminazioni
  1. 72 1
      src/bin/codegraph.ts
  2. 3 0
      src/directory.ts
  3. 87 0
      src/installer/config-writer.ts
  4. 13 3
      src/installer/index.ts

+ 72 - 1
src/bin/codegraph.ts

@@ -15,6 +15,8 @@
  *   codegraph query <search>     Search for symbols
  *   codegraph files [options]    Show project file structure
  *   codegraph context <task>     Build context for a task
+ *   codegraph mark-dirty [path]  Mark project as needing sync (hooks)
+ *   codegraph sync-if-dirty [path] Sync if marked dirty (hooks)
  *
  * Note: Git hooks have been removed. CodeGraph sync is triggered automatically
  * through codegraph's Claude Code hooks integration.
@@ -23,7 +25,7 @@
 import { Command } from 'commander';
 import * as path from 'path';
 import * as fs from 'fs';
-import CodeGraph from '../index';
+import CodeGraph, { getCodeGraphDir, findNearestCodeGraphRoot } from '../index';
 import type { IndexProgress } from '../index';
 import { runInstaller } from '../installer';
 import { initSentry, captureException } from '../sentry';
@@ -926,6 +928,75 @@ program
     }
   });
 
+/**
+ * codegraph mark-dirty [path]
+ *
+ * Touches .codegraph/.dirty to signal that files have changed.
+ * Used by Claude Code PostToolUse hooks to batch syncs.
+ * Runs silently and always exits 0.
+ */
+program
+  .command('mark-dirty [path]')
+  .description('Mark project as needing sync (used by Claude Code hooks)')
+  .action(async (pathArg: string | undefined) => {
+    try {
+      const startPath = path.resolve(pathArg || process.cwd());
+      const projectRoot = findNearestCodeGraphRoot(startPath);
+      if (!projectRoot) {
+        // No .codegraph/ found — exit silently
+        process.exit(0);
+      }
+      const dirtyPath = path.join(getCodeGraphDir(projectRoot), '.dirty');
+      fs.writeFileSync(dirtyPath, Date.now().toString(), 'utf-8');
+    } catch {
+      // Never fail — this runs in the background during edits
+    }
+    process.exit(0);
+  });
+
+/**
+ * codegraph sync-if-dirty [path]
+ *
+ * Syncs the index only if .codegraph/.dirty exists.
+ * Removes the marker BEFORE syncing so edits during sync
+ * create a new marker for the next Stop event.
+ * Runs silently and always exits 0.
+ */
+program
+  .command('sync-if-dirty [path]')
+  .description('Sync if project was marked dirty (used by Claude Code hooks)')
+  .action(async (pathArg: string | undefined) => {
+    try {
+      const startPath = path.resolve(pathArg || process.cwd());
+      const projectRoot = findNearestCodeGraphRoot(startPath);
+      if (!projectRoot) {
+        process.exit(0);
+      }
+      const dirtyPath = path.join(getCodeGraphDir(projectRoot), '.dirty');
+
+      // No marker → nothing to do (sub-ms exit)
+      if (!fs.existsSync(dirtyPath)) {
+        process.exit(0);
+      }
+
+      // Remove marker FIRST so edits during sync create a new one
+      try { fs.unlinkSync(dirtyPath); } catch { /* ignore */ }
+
+      // If not fully initialized (no DB), exit
+      if (!CodeGraph.isInitialized(projectRoot)) {
+        process.exit(0);
+      }
+
+      // Run sync
+      const cg = await CodeGraph.open(projectRoot);
+      await cg.sync();
+      cg.destroy();
+    } catch {
+      // Never fail — this runs at the end of Claude responses
+    }
+    process.exit(0);
+  });
+
 /**
  * codegraph install
  */

+ 3 - 0
src/directory.ts

@@ -96,6 +96,9 @@ cache/
 
 # Logs
 *.log
+
+# Hook markers
+.dirty
 `;
 
     fs.writeFileSync(gitignorePath, gitignoreContent, 'utf-8');

+ 87 - 0
src/installer/config-writer.ts

@@ -175,6 +175,93 @@ export function hasPermissions(location: InstallLocation): boolean {
   return permissions.some((p: string) => p.startsWith('mcp__codegraph__'));
 }
 
+// =============================================================================
+// Hooks Configuration
+// =============================================================================
+
+/**
+ * Get the hooks configuration for Claude Code auto-sync.
+ *
+ * PostToolUse(Edit|Write) → mark-dirty (async, non-blocking)
+ * Stop → sync-if-dirty (sync, ensures fresh index before next user turn)
+ */
+function getHooksConfig(location: InstallLocation): Record<string, any> {
+  const command = location === 'global' ? 'codegraph' : 'npx @colbymchenry/codegraph';
+
+  return {
+    PostToolUse: [
+      {
+        matcher: 'Edit|Write',
+        hooks: [
+          {
+            type: 'command',
+            command: `${command} mark-dirty`,
+            async: true,
+          },
+        ],
+      },
+    ],
+    Stop: [
+      {
+        hooks: [
+          {
+            type: 'command',
+            command: `${command} sync-if-dirty`,
+          },
+        ],
+      },
+    ],
+  };
+}
+
+/**
+ * Check if Claude Code hooks already exist for CodeGraph
+ */
+export function hasHooks(location: InstallLocation): boolean {
+  const settingsPath = getSettingsJsonPath(location);
+  const settings = readJsonFile(settingsPath);
+  const hooks = settings.hooks;
+  if (!hooks) return false;
+
+  // Check if any hook command references codegraph
+  const json = JSON.stringify(hooks);
+  return json.includes('codegraph mark-dirty') || json.includes('codegraph sync-if-dirty');
+}
+
+/**
+ * Write Claude Code hooks to settings.json for auto-sync.
+ * Merges with existing hooks, deduplicating any previous codegraph entries.
+ */
+export function writeHooks(location: InstallLocation): void {
+  const settingsPath = getSettingsJsonPath(location);
+  const settings = readJsonFile(settingsPath);
+
+  if (!settings.hooks) {
+    settings.hooks = {};
+  }
+
+  const newHooks = getHooksConfig(location);
+
+  // For each hook event (PostToolUse, Stop), merge with existing entries
+  for (const [event, newEntries] of Object.entries(newHooks)) {
+    if (!Array.isArray(settings.hooks[event])) {
+      settings.hooks[event] = [];
+    }
+
+    // Remove any existing codegraph entries for this event
+    settings.hooks[event] = (settings.hooks[event] as any[]).filter((entry: any) => {
+      // Keep entries that don't reference codegraph
+      const entryJson = JSON.stringify(entry);
+      return !entryJson.includes('codegraph mark-dirty') && !entryJson.includes('codegraph sync-if-dirty');
+    });
+
+    // Add new codegraph entries
+    settings.hooks[event].push(...(newEntries as any[]));
+  }
+
+  writeJsonFile(settingsPath, settings);
+}
+
 /**
  * Get the path to CLAUDE.md
  * - Global: ~/.claude/CLAUDE.md

+ 13 - 3
src/installer/index.ts

@@ -8,7 +8,7 @@
 import { execSync } from 'child_process';
 import { showBanner, showNextSteps, success, error, info, chalk } from './banner';
 import { promptInstallLocation, promptAutoAllow, InstallLocation } from './prompts';
-import { writeMcpConfig, writePermissions, writeClaudeMd, hasMcpConfig, hasPermissions } from './config-writer';
+import { writeMcpConfig, writePermissions, writeClaudeMd, writeHooks, hasMcpConfig, hasPermissions, hasHooks } from './config-writer';
 import CodeGraph from '../index';
 
 /**
@@ -76,7 +76,17 @@ export async function runInstaller(): Promise<void> {
       }
     }
 
-    // Step 5: Write CLAUDE.md instructions
+    // Step 5: Write auto-sync hooks
+    const alreadyHasHooks = hasHooks(location);
+    writeHooks(location);
+
+    if (alreadyHasHooks) {
+      success(`Updated auto-sync hooks in ${location === 'global' ? '~/.claude/settings.json' : './.claude/settings.json'}`);
+    } else {
+      success(`Added auto-sync hooks to ${location === 'global' ? '~/.claude/settings.json' : './.claude/settings.json'}`);
+    }
+
+    // Step 6: Write CLAUDE.md instructions
     const claudeMdResult = writeClaudeMd(location);
     const claudeMdPath = location === 'global' ? '~/.claude/CLAUDE.md' : './.claude/CLAUDE.md';
 
@@ -88,7 +98,7 @@ export async function runInstaller(): Promise<void> {
       success(`Added CodeGraph instructions to ${claudeMdPath}`);
     }
 
-    // Step 6: For local install, initialize the project
+    // Step 7: For local install, initialize the project
     if (location === 'local') {
       await initializeLocalProject();
     }