diff --git a/README.md b/README.md index 5f2255889..dbb79ab67 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,11 @@ If you prefer manual configuration: **Claude Code** (full support — MCP + skills + hooks): ```bash +# macOS / Linux claude mcp add gitnexus -- npx -y gitnexus@latest mcp + +# Windows +claude mcp add gitnexus -- cmd /c npx -y gitnexus@latest mcp ``` **Codex** (full support — MCP + skills): diff --git a/gitnexus/README.md b/gitnexus/README.md index 43c75db17..43979d6ab 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -53,7 +53,11 @@ If you prefer to configure manually instead of using `gitnexus setup`: ### Claude Code (full support — MCP + skills + hooks) ```bash +# macOS / Linux claude mcp add gitnexus -- npx -y gitnexus@latest mcp + +# Windows +claude mcp add gitnexus -- cmd /c npx -y gitnexus@latest mcp ``` ### Codex (full support — MCP + skills) diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index eb8149f07..851037a17 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -111,20 +111,21 @@ async function setupCursor(result: SetupResult): Promise { async function setupClaudeCode(result: SetupResult): Promise { const claudeDir = path.join(os.homedir(), '.claude'); - const hasClaude = await dirExists(claudeDir); - - if (!hasClaude) { + if (!(await dirExists(claudeDir))) { result.skipped.push('Claude Code (not installed)'); return; } - // Claude Code uses a JSON settings file at ~/.claude.json or claude mcp add - console.log(''); - console.log(' Claude Code detected. Run this command to add GitNexus MCP:'); - console.log(''); - console.log(' claude mcp add gitnexus -- npx -y gitnexus mcp'); - console.log(''); - result.configured.push('Claude Code (MCP manual step printed)'); + // Claude Code stores MCP config in ~/.claude.json + const mcpPath = path.join(os.homedir(), '.claude.json'); + try { + const existing = await readJsonFile(mcpPath); + const updated = mergeMcpConfig(existing); + await writeJsonFile(mcpPath, updated); + result.configured.push('Claude Code'); + } catch (err: any) { + result.errors.push(`Claude Code: ${err.message}`); + } } /** diff --git a/gitnexus/test/unit/setup.test.ts b/gitnexus/test/unit/setup.test.ts new file mode 100644 index 000000000..4dd570b29 --- /dev/null +++ b/gitnexus/test/unit/setup.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +const execFileMock = vi.fn((...args: any[]) => { + const callback = args.at(-1); + if (typeof callback === 'function') { + callback(null, '', ''); + } +}); + +vi.mock('child_process', () => ({ + execFile: execFileMock, +})); + +describe('setupClaudeCode', () => { + let tempHome: string; + let originalHome: string | undefined; + let originalUserProfile: string | undefined; + let platformDescriptor: PropertyDescriptor | undefined; + + const setPlatform = (value: NodeJS.Platform) => { + Object.defineProperty(process, 'platform', { + value, + configurable: true, + }); + }; + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + + originalHome = process.env.HOME; + originalUserProfile = process.env.USERPROFILE; + tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-claude-setup-')); + process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; + + // Only create ~/.claude — no other editor directories so their + // setup functions skip and don't pollute assertions. + await fs.mkdir(path.join(tempHome, '.claude'), { recursive: true }); + + platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor); + } + + process.env.HOME = originalHome; + process.env.USERPROFILE = originalUserProfile; + await fs.rm(tempHome, { recursive: true, force: true }); + }); + + it('writes win32 MCP entry with cmd wrapper', async () => { + setPlatform('win32'); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8'); + const config = JSON.parse(raw); + + expect(config.mcpServers.gitnexus).toEqual({ + command: 'cmd', + args: ['/c', 'npx', '-y', 'gitnexus@latest', 'mcp'], + }); + }); + + it('writes non-win32 MCP entry with npx directly', async () => { + setPlatform('darwin'); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8'); + const config = JSON.parse(raw); + + expect(config.mcpServers.gitnexus).toEqual({ + command: 'npx', + args: ['-y', 'gitnexus@latest', 'mcp'], + }); + }); + + it('skips when ~/.claude directory does not exist', async () => { + await fs.rm(path.join(tempHome, '.claude'), { recursive: true, force: true }); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + await expect( + fs.access(path.join(tempHome, '.claude.json')), + ).rejects.toThrow(); + }); + + it('preserves existing keys in ~/.claude.json', async () => { + setPlatform('linux'); + + await fs.writeFile( + path.join(tempHome, '.claude.json'), + JSON.stringify({ existingKey: 'keep-me', mcpServers: { other: { command: 'foo' } } }), + 'utf-8', + ); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8'); + const config = JSON.parse(raw); + + expect(config.existingKey).toBe('keep-me'); + expect(config.mcpServers.other).toEqual({ command: 'foo' }); + expect(config.mcpServers.gitnexus).toBeDefined(); + }); + + it('handles missing ~/.claude.json (creates fresh)', async () => { + setPlatform('linux'); + + // Ensure no pre-existing file + await fs.rm(path.join(tempHome, '.claude.json'), { force: true }); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8'); + const config = JSON.parse(raw); + + expect(config.mcpServers.gitnexus).toBeDefined(); + }); + + it('handles corrupt JSON gracefully', async () => { + setPlatform('linux'); + + await fs.writeFile( + path.join(tempHome, '.claude.json'), + '{ this is not valid json !!!', + 'utf-8', + ); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + // readJsonFile returns null on invalid JSON, so mergeMcpConfig + // creates a fresh config — the file should now be valid. + const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8'); + const config = JSON.parse(raw); + + expect(config.mcpServers.gitnexus).toBeDefined(); + }); +});