@@ -13,7 +13,7 @@ import { execFile, execFileSync } from 'child_process';
1313import { promisify } from 'util' ;
1414import { fileURLToPath } from 'url' ;
1515import { glob } from 'glob' ;
16- import { parseTree , modify , applyEdits , ParseError } from 'jsonc-parser' ;
16+ import { parseTree , modify , applyEdits , ParseError , parse as parseJsonc } from 'jsonc-parser' ;
1717import { getGlobalDir } from '../storage/repo-manager.js' ;
1818
1919const __filename = fileURLToPath ( import . meta. url ) ;
@@ -93,41 +93,6 @@ function getOpenCodeMcpEntry() {
9393 return { type : 'local' , command : [ 'npx' , '-y' , 'gitnexus@latest' , 'mcp' ] } ;
9494}
9595
96- /**
97- * Merge gitnexus entry into an existing MCP config JSON object.
98- * Returns the updated config.
99- */
100- function mergeMcpConfig ( existing : any ) : any {
101- if ( ! existing || typeof existing !== 'object' ) {
102- existing = { } ;
103- }
104- if ( ! existing . mcpServers || typeof existing . mcpServers !== 'object' ) {
105- existing . mcpServers = { } ;
106- }
107- existing . mcpServers . gitnexus = getMcpEntry ( ) ;
108- return existing ;
109- }
110-
111- /**
112- * Try to read a JSON file, returning null if it doesn't exist or is invalid.
113- */
114- async function readJsonFile ( filePath : string ) : Promise < any | null > {
115- try {
116- const raw = await fs . readFile ( filePath , 'utf-8' ) ;
117- return JSON . parse ( raw ) ;
118- } catch {
119- return null ;
120- }
121- }
122-
123- /**
124- * Write JSON to a file, creating parent directories if needed.
125- */
126- async function writeJsonFile ( filePath : string , data : any ) : Promise < void > {
127- await fs . mkdir ( path . dirname ( filePath ) , { recursive : true } ) ;
128- await fs . writeFile ( filePath , JSON . stringify ( data , null , 2 ) + '\n' , 'utf-8' ) ;
129- }
130-
13196/**
13297 * Detect indentation style from file content.
13398 * Returns formatting options matching the file's existing style.
@@ -156,17 +121,11 @@ async function mergeJsoncFile(
156121 }
157122
158123 if ( raw . trim ( ) . length === 0 ) {
159- const config : any = { } ;
160- let parent : any = config ;
161- for ( let i = 0 ; i < keyPath . length ; i ++ ) {
162- if ( i === keyPath . length - 1 ) {
163- parent [ keyPath [ i ] ] = value ;
164- } else {
165- parent [ keyPath [ i ] ] = { } ;
166- parent = parent [ keyPath [ i ] ] ;
167- }
168- }
169- await writeJsonFile ( filePath , config ) ;
124+ await fs . mkdir ( path . dirname ( filePath ) , { recursive : true } ) ;
125+ const formattingOptions = { tabSize : 2 , insertSpaces : true } ;
126+ const edits = modify ( '{}' , keyPath , value , { formattingOptions } ) ;
127+ const result = applyEdits ( '{}' , edits ) ;
128+ await fs . writeFile ( filePath , result , 'utf-8' ) ;
170129 return true ;
171130 }
172131
@@ -207,10 +166,12 @@ async function setupCursor(result: SetupResult): Promise<void> {
207166
208167 const mcpPath = path . join ( cursorDir , 'mcp.json' ) ;
209168 try {
210- const existing = await readJsonFile ( mcpPath ) ;
211- const updated = mergeMcpConfig ( existing ) ;
212- await writeJsonFile ( mcpPath , updated ) ;
213- result . configured . push ( 'Cursor' ) ;
169+ const ok = await mergeJsoncFile ( mcpPath , [ 'mcpServers' , 'gitnexus' ] , getMcpEntry ( ) ) ;
170+ if ( ok ) {
171+ result . configured . push ( 'Cursor' ) ;
172+ } else {
173+ result . errors . push ( 'Cursor: mcp.json is corrupt — skipping to preserve existing content' ) ;
174+ }
214175 } catch ( err : any ) {
215176 result . errors . push ( `Cursor: ${ err . message } ` ) ;
216177 }
@@ -226,10 +187,14 @@ async function setupClaudeCode(result: SetupResult): Promise<void> {
226187 // Claude Code stores MCP config in ~/.claude.json
227188 const mcpPath = path . join ( os . homedir ( ) , '.claude.json' ) ;
228189 try {
229- const existing = await readJsonFile ( mcpPath ) ;
230- const updated = mergeMcpConfig ( existing ) ;
231- await writeJsonFile ( mcpPath , updated ) ;
232- result . configured . push ( 'Claude Code' ) ;
190+ const ok = await mergeJsoncFile ( mcpPath , [ 'mcpServers' , 'gitnexus' ] , getMcpEntry ( ) ) ;
191+ if ( ok ) {
192+ result . configured . push ( 'Claude Code' ) ;
193+ } else {
194+ result . errors . push (
195+ 'Claude Code: .claude.json is corrupt — skipping to preserve existing content' ,
196+ ) ;
197+ }
233198 } catch ( err : any ) {
234199 result . errors . push ( `Claude Code: ${ err . message } ` ) ;
235200 }
@@ -253,9 +218,90 @@ async function installClaudeCodeSkills(result: SetupResult): Promise<void> {
253218 }
254219}
255220
221+ /**
222+ * Check whether an event array already contains a gitnexus-hook entry.
223+ */
224+ function hasGitnexusHook ( hooksObj : any , eventName : string ) : boolean {
225+ const entries = hooksObj ?. [ eventName ] ;
226+ if ( ! Array . isArray ( entries ) ) return false ;
227+ return entries . some (
228+ ( h : any ) =>
229+ Array . isArray ( h . hooks ) &&
230+ h . hooks . some (
231+ ( hh : any ) => typeof hh . command === 'string' && hh . command . includes ( 'gitnexus-hook' ) ,
232+ ) ,
233+ ) ;
234+ }
235+
236+ /**
237+ * Merge hook entries into a JSONC settings file, preserving comments and formatting.
238+ * Uses chained modify()+applyEdits() calls to append to arrays without a full
239+ * JSON.stringify roundtrip that would strip comments.
240+ */
241+ async function mergeHooksJsonc (
242+ filePath : string ,
243+ entries : Array < { eventName : string ; value : unknown } > ,
244+ ) : Promise < boolean > {
245+ let raw : string ;
246+ try {
247+ raw = await fs . readFile ( filePath , 'utf-8' ) ;
248+ } catch {
249+ raw = '' ;
250+ }
251+
252+ if ( raw . trim ( ) . length === 0 ) {
253+ await fs . mkdir ( path . dirname ( filePath ) , { recursive : true } ) ;
254+ const hooks : any = { } ;
255+ for ( const { eventName, value } of entries ) {
256+ hooks [ eventName ] = [ value ] ;
257+ }
258+ const formattingOptions = { tabSize : 2 , insertSpaces : true } ;
259+ const edits = modify ( '{}' , [ 'hooks' ] , hooks , { formattingOptions } ) ;
260+ await fs . writeFile ( filePath , applyEdits ( '{}' , edits ) , 'utf-8' ) ;
261+ return true ;
262+ }
263+
264+ const parseErrors : ParseError [ ] = [ ] ;
265+ const tree = parseTree ( raw , parseErrors ) ;
266+
267+ if ( ! tree || tree . type !== 'object' || parseErrors . length > 0 ) {
268+ return false ;
269+ }
270+
271+ const formattingOptions = detectIndentation ( raw ) ;
272+ let current = raw ;
273+
274+ for ( const { eventName, value } of entries ) {
275+ // Re-parse after each edit to get a fresh insertion index.
276+ const currentTree = parseTree ( current , [ ] ) ;
277+ const hooksNode = currentTree ?. children ?. find (
278+ ( c ) => c . type === 'property' && c . children ?. [ 0 ] ?. value === 'hooks' ,
279+ ) ;
280+ const eventNode = hooksNode ?. children ?. [ 1 ] ?. children ?. find (
281+ ( c : any ) => c . type === 'property' && c . children ?. [ 0 ] ?. value === eventName ,
282+ ) ;
283+
284+ let insertIndex : number ;
285+ if ( eventNode ?. children ?. [ 1 ] && Array . isArray ( eventNode . children [ 1 ] . children ) ) {
286+ insertIndex = eventNode . children [ 1 ] . children . length ;
287+ } else {
288+ insertIndex = 0 ;
289+ }
290+
291+ const edits = modify ( current , [ 'hooks' , eventName , insertIndex ] , value , {
292+ formattingOptions,
293+ } ) ;
294+ current = applyEdits ( current , edits ) ;
295+ }
296+
297+ await fs . writeFile ( filePath , current , 'utf-8' ) ;
298+ return true ;
299+ }
300+
256301/**
257302 * Install GitNexus hooks to ~/.claude/settings.json for Claude Code.
258- * Merges hook config without overwriting existing hooks.
303+ * Merges hook config without overwriting existing hooks, preserving
304+ * comments and formatting in the JSONC file.
259305 */
260306async function installClaudeCodeHooks ( result : SetupResult ) : Promise < void > {
261307 const claudeDir = path . join ( os . homedir ( ) , '.claude' ) ;
@@ -276,8 +322,6 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
276322 const dest = path . join ( destHooksDir , 'gitnexus-hook.cjs' ) ;
277323 try {
278324 let content = await fs . readFile ( src , 'utf-8' ) ;
279- // Inject resolved CLI path so the copied hook can find the CLI
280- // even when it's no longer inside the npm package tree
281325 const resolvedCli = path . join ( __dirname , '..' , 'cli' , 'index.js' ) ;
282326 const normalizedCli = path . resolve ( resolvedCli ) . replace ( / \\ / g, '/' ) ;
283327 const jsonCli = JSON . stringify ( normalizedCli ) ;
@@ -293,40 +337,67 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
293337 const hookPath = path . join ( destHooksDir , 'gitnexus-hook.cjs' ) . replace ( / \\ / g, '/' ) ;
294338 const hookCmd = `node "${ hookPath . replace ( / " / g, '\\"' ) } "` ;
295339
296- // Merge hook config into ~/.claude/settings.json
297- const existing = ( await readJsonFile ( settingsPath ) ) || { } ;
298- if ( ! existing . hooks ) existing . hooks = { } ;
340+ // Check which hook events need entries (idempotent: skip if already registered)
341+ const parsed = await ( async ( ) => {
342+ try {
343+ const r = await fs . readFile ( settingsPath , 'utf-8' ) ;
344+ return parseJsonc ( r ) ;
345+ } catch {
346+ return null ;
347+ }
348+ } ) ( ) ;
349+
350+ const hookEntries : Array < { eventName : string ; value : unknown } > = [ ] ;
299351
300352 // NOTE: SessionStart hooks are broken on Windows (Claude Code bug #23576).
301353 // Session context is delivered via CLAUDE.md / skills instead.
302354
303- // Helper: add a hook entry if one with 'gitnexus-hook' isn't already registered
304- interface HookEntry {
305- hooks ?: Array < { command ?: string } > ;
355+ if ( ! hasGitnexusHook ( parsed ?. hooks , 'PreToolUse' ) ) {
356+ hookEntries . push ( {
357+ eventName : 'PreToolUse' ,
358+ value : {
359+ matcher : 'Grep|Glob|Bash' ,
360+ hooks : [
361+ {
362+ type : 'command' ,
363+ command : hookCmd ,
364+ timeout : 10 ,
365+ statusMessage : 'Enriching with GitNexus graph context...' ,
366+ } ,
367+ ] ,
368+ } ,
369+ } ) ;
306370 }
307- function ensureHookEntry (
308- eventName : string ,
309- matcher : string ,
310- timeout : number ,
311- statusMessage : string ,
312- ) {
313- if ( ! existing . hooks [ eventName ] ) existing . hooks [ eventName ] = [ ] ;
314- const hasHook = existing . hooks [ eventName ] . some ( ( h : HookEntry ) =>
315- h . hooks ?. some ( ( hh ) => hh . command ?. includes ( 'gitnexus-hook' ) ) ,
316- ) ;
317- if ( ! hasHook ) {
318- existing . hooks [ eventName ] . push ( {
319- matcher,
320- hooks : [ { type : 'command' , command : hookCmd , timeout, statusMessage } ] ,
321- } ) ;
322- }
371+ if ( ! hasGitnexusHook ( parsed ?. hooks , 'PostToolUse' ) ) {
372+ hookEntries . push ( {
373+ eventName : 'PostToolUse' ,
374+ value : {
375+ matcher : 'Bash' ,
376+ hooks : [
377+ {
378+ type : 'command' ,
379+ command : hookCmd ,
380+ timeout : 10 ,
381+ statusMessage : 'Checking GitNexus index freshness...' ,
382+ } ,
383+ ] ,
384+ } ,
385+ } ) ;
323386 }
324387
325- ensureHookEntry ( 'PreToolUse' , 'Grep|Glob|Bash' , 10 , 'Enriching with GitNexus graph context...' ) ;
326- ensureHookEntry ( 'PostToolUse' , 'Bash' , 10 , 'Checking GitNexus index freshness...' ) ;
388+ if ( hookEntries . length === 0 ) {
389+ result . configured . push ( 'Claude Code hooks (already configured)' ) ;
390+ return ;
391+ }
327392
328- await writeJsonFile ( settingsPath , existing ) ;
329- result . configured . push ( 'Claude Code hooks (PreToolUse, PostToolUse)' ) ;
393+ const ok = await mergeHooksJsonc ( settingsPath , hookEntries ) ;
394+ if ( ok ) {
395+ result . configured . push ( 'Claude Code hooks (PreToolUse, PostToolUse)' ) ;
396+ } else {
397+ result . errors . push (
398+ 'Claude Code hooks: settings.json is corrupt — skipping to preserve existing content' ,
399+ ) ;
400+ }
330401 } catch ( err : any ) {
331402 result . errors . push ( `Claude Code hooks: ${ err . message } ` ) ;
332403 }
0 commit comments