diff --git a/gitnexus/README.md b/gitnexus/README.md index 8c08c7949..6e0f6e05d 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -36,7 +36,7 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up `gitnexus setup` auto-detects your editors and writes the correct global MCP config. You only need to run it once. To configure only selected integrations, pass `--coding-agent`/`-c` with a comma-separated list or repeat the option, for example `gitnexus setup -c cursor,codex`. -For Claude Code, Cursor, and Codex, setup keeps an existing URL-based `gitnexus` MCP entry and its authentication settings; skills and hooks are still installed. +For Claude Code, Cursor, and Codex, setup keeps an existing URL-based `gitnexus` MCP entry and its authentication settings. Skills are still installed; setup installs Claude Code and Codex hooks, while Cursor hooks require [manual installation](../gitnexus-cursor-integration/README.md#hook-install). ### Editor Support diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index 1b8e21e87..88a9eed93 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -21,7 +21,7 @@ import { ParseError, parse as parseJsonc, } from 'jsonc-parser'; -import { parse as parseToml } from 'smol-toml'; +import { parse as parseToml, TomlError } from 'smol-toml'; import { packageVersion } from '../core/package-version.js'; import { getGlobalDir } from '../storage/repo-manager.js'; import { @@ -1091,19 +1091,27 @@ async function codexHasHttpMcpEntry(configPath: string): Promise { } async function setupCodex(result: SetupResult): Promise { - const codexDir = path.join(os.homedir(), '.codex'); - if (!(await dirExists(codexDir))) { + const configuredHome = process.env.CODEX_HOME; + const codexDir = configuredHome + ? path.resolve(configuredHome) + : path.join(os.homedir(), '.codex'); + if (!configuredHome && !(await dirExists(codexDir))) { result.skipped.push('Codex (not installed)'); return; } + const configPath = path.join(codexDir, 'config.toml'); try { - if (await codexHasHttpMcpEntry(getEditorTargets().codex.configFile)) { + if (await codexHasHttpMcpEntry(configPath)) { result.configured.push('Codex (existing HTTP MCP entry kept)'); return; } } catch (err) { - result.errors.push(`Codex: ${err instanceof Error ? err.message : String(err)}`); + result.errors.push( + err instanceof TomlError + ? `Codex: invalid config.toml (line ${err.line}, column ${err.column}); MCP entry unchanged` + : `Codex: ${err instanceof Error ? err.message : String(err)}`, + ); return; } @@ -1120,9 +1128,8 @@ async function setupCodex(result: SetupResult): Promise { } try { - const configPath = getEditorTargets().codex.configFile; await upsertCodexConfigToml(configPath); - result.configured.push('Codex (MCP added to ~/.codex/config.toml)'); + result.configured.push('Codex (MCP added to active config.toml)'); } catch (err: any) { result.errors.push(`Codex: ${err.message}`); } diff --git a/gitnexus/test/unit/setup-codex.test.ts b/gitnexus/test/unit/setup-codex.test.ts index c3c93f62a..7eec4f3d6 100644 --- a/gitnexus/test/unit/setup-codex.test.ts +++ b/gitnexus/test/unit/setup-codex.test.ts @@ -27,6 +27,7 @@ describe('setupCommand codex execution', () => { let tempHome: string; let originalHome: string | undefined; let originalUserProfile: string | undefined; + let originalCodexHome: string | undefined; let platformDescriptor: PropertyDescriptor | undefined; const setPlatform = (value: NodeJS.Platform) => { @@ -42,6 +43,8 @@ describe('setupCommand codex execution', () => { originalHome = process.env.HOME; originalUserProfile = process.env.USERPROFILE; + originalCodexHome = process.env.CODEX_HOME; + delete process.env.CODEX_HOME; tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-codex-setup-')); process.env.HOME = tempHome; process.env.USERPROFILE = tempHome; @@ -62,6 +65,8 @@ describe('setupCommand codex execution', () => { process.env.HOME = originalHome; process.env.USERPROFILE = originalUserProfile; + if (originalCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = originalCodexHome; await fs.rm(tempHome, { recursive: true, force: true }); }); @@ -126,6 +131,77 @@ describe('setupCommand codex execution', () => { ).toBeDefined(); }); + it('does not mistake an HTTP entry in the default home for the active CODEX_HOME entry', async () => { + const defaultConfig = path.join(tempHome, '.codex', 'config.toml'); + const activeHome = path.join(tempHome, 'active-codex'); + await fs.writeFile(defaultConfig, '[mcp_servers.gitnexus]\nurl = "https://example.test/mcp"\n'); + await fs.mkdir(activeHome); + process.env.CODEX_HOME = activeHome; + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand({ codingAgent: 'codex' }); + + expect(execFileMock).toHaveBeenCalledWith( + 'codex', + ['mcp', 'add', 'gitnexus', '--', 'cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'], + { shell: true, windowsHide: true }, + expect.any(Function), + ); + }); + + it('preserves an HTTP entry in the active CODEX_HOME even when the default home is empty', async () => { + const activeHome = path.join(tempHome, 'active-codex'); + const activeConfig = path.join(activeHome, 'config.toml'); + const raw = '[mcp_servers.gitnexus]\nurl = "https://example.test/mcp"\n'; + await fs.mkdir(activeHome); + await fs.writeFile(activeConfig, raw); + process.env.CODEX_HOME = activeHome; + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand({ codingAgent: 'codex' }); + + expect(execFileMock).not.toHaveBeenCalled(); + expect(await fs.readFile(activeConfig, 'utf-8')).toBe(raw); + }); + + it('writes the fallback entry to the active CODEX_HOME when the Codex CLI is unavailable', async () => { + const activeHome = path.join(tempHome, 'active-codex'); + process.env.CODEX_HOME = activeHome; + execFileMock.mockImplementationOnce((...args: unknown[]) => { + const callback = args.at(-1); + if (typeof callback === 'function') callback(new Error('codex unavailable')); + }); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand({ codingAgent: 'codex' }); + + expect(await fs.readFile(path.join(activeHome, 'config.toml'), 'utf-8')).toContain( + '[mcp_servers.gitnexus]', + ); + await expect(fs.access(path.join(tempHome, '.codex', 'config.toml'))).rejects.toThrow(); + }); + + it('does not print TOML source or nearby credentials when the active config is malformed', async () => { + const activeHome = path.join(tempHome, 'active-codex'); + const configPath = path.join(activeHome, 'config.toml'); + const token = 'Bearer FAKE_PR3460_REVIEW_TOKEN'; + await fs.mkdir(activeHome); + await fs.writeFile( + configPath, + `[mcp_servers.gitnexus]\nhttp_headers = { Authorization = "${token}" }\ninvalid =\n`, + ); + process.env.CODEX_HOME = activeHome; + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand({ codingAgent: 'codex' }); + + const output = JSON.stringify(vi.mocked(console.log).mock.calls); + expect(output).toMatch(/invalid config\.toml \(line 3, column \d+\)/); + expect(output).not.toContain(token); + expect(execFileMock).not.toHaveBeenCalled(); + expect(await fs.readFile(configPath, 'utf-8')).toContain(token); + }); + it('skips Codex setup entirely when ~/.codex is missing', async () => { await fs.rm(path.join(tempHome, '.codex'), { recursive: true, force: true });