Skip to content

Commit 57808ef

Browse files
authored
refactor(setup): migrate all config I/O to mergeJsoncFile (#1031)
1 parent 3eeb283 commit 57808ef

3 files changed

Lines changed: 477 additions & 94 deletions

File tree

gitnexus/src/cli/setup.ts

Lines changed: 155 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { execFile, execFileSync } from 'child_process';
1313
import { promisify } from 'util';
1414
import { fileURLToPath } from 'url';
1515
import { glob } from 'glob';
16-
import { parseTree, modify, applyEdits, ParseError } from 'jsonc-parser';
16+
import { parseTree, modify, applyEdits, ParseError, parse as parseJsonc } from 'jsonc-parser';
1717
import { getGlobalDir } from '../storage/repo-manager.js';
1818

1919
const __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
*/
260306
async 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

Comments
 (0)