diff --git a/gitnexus/src/cli/editor-targets.ts b/gitnexus/src/cli/editor-targets.ts index 600fde2d9..f3267aa4b 100644 --- a/gitnexus/src/cli/editor-targets.ts +++ b/gitnexus/src/cli/editor-targets.ts @@ -208,10 +208,10 @@ export function getEditorTargets(home: string = os.homedir()): EditorTargets { // Codex hooks use Claude Code's exact {hooks: {Event: [...]}} JSON shape // and hookSpecificOutput response contract, in a dedicated hooks.json // (https://developers.openai.com/codex/hooks). - settingsFile: path.join(home, '.codex', 'hooks.json'), + settingsFile: path.join(codexHome, 'hooks.json'), events: ['PreToolUse', 'PostToolUse'], needle: 'gitnexus-hook', - scriptDir: path.join(home, '.codex', 'hooks', 'gitnexus'), + scriptDir: path.join(codexHome, 'hooks', 'gitnexus'), }, { id: 'antigravity', diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index cdb778af5..33de0ad6b 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -1325,7 +1325,7 @@ async function installOpenCodeSkills(result: SetupResult): Promise { * Install global Codex skills to ~/.agents/skills/gitnexus/ */ async function installCodexSkills(result: SetupResult): Promise { - const codexDir = path.join(os.homedir(), '.codex'); + const codexDir = path.dirname(getEditorTargets().codex.configFile); if (!(await dirExists(codexDir))) return; const skillsDir = skillTarget('codex').dir; diff --git a/gitnexus/test/unit/setup.test.ts b/gitnexus/test/unit/setup.test.ts index 289796342..4f7b098a2 100644 --- a/gitnexus/test/unit/setup.test.ts +++ b/gitnexus/test/unit/setup.test.ts @@ -834,6 +834,7 @@ describe('Codex hooks (installClaudeSchemaHooks)', () => { let tempHome: string; let originalHome: string | undefined; let originalUserProfile: string | undefined; + let originalCodexHome: string | undefined; const hooksJsonPath = () => path.join(tempHome, '.codex', 'hooks.json'); @@ -843,6 +844,8 @@ describe('Codex hooks (installClaudeSchemaHooks)', () => { 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-hooks-')); process.env.HOME = tempHome; process.env.USERPROFILE = tempHome; @@ -858,6 +861,8 @@ describe('Codex hooks (installClaudeSchemaHooks)', () => { vi.restoreAllMocks(); 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 }); }); @@ -878,6 +883,28 @@ describe('Codex hooks (installClaudeSchemaHooks)', () => { ).resolves.toBeUndefined(); }); + it('installs hooks and skills for active CODEX_HOME without touching the default home', async () => { + const activeHome = path.join(tempHome, 'active-codex'); + const defaultHooks = hooksJsonPath(); + await fs.rm(path.join(tempHome, '.codex'), { recursive: true, force: true }); + await fs.mkdir(activeHome); + process.env.CODEX_HOME = activeHome; + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand({ codingAgent: 'codex' }); + + const hooks = JSON.parse(await fs.readFile(path.join(activeHome, 'hooks.json'), 'utf-8')).hooks; + expect(hooks.PreToolUse).toHaveLength(1); + expect(hooks.PostToolUse).toHaveLength(1); + await expect( + fs.access(path.join(activeHome, 'hooks', 'gitnexus', 'gitnexus-hook.cjs')), + ).resolves.toBeUndefined(); + await expect( + fs.access(path.join(tempHome, '.agents', 'skills', 'gitnexus-guide', 'SKILL.md')), + ).resolves.toBeUndefined(); + await expect(fs.access(defaultHooks)).rejects.toThrow(); + }); + it('is idempotent — a second setup run adds no duplicate entries', async () => { const { setupCommand } = await import('../../src/cli/setup.js'); await setupCommand(); diff --git a/gitnexus/test/unit/uninstall.test.ts b/gitnexus/test/unit/uninstall.test.ts index 644ca785f..5c3a2e285 100644 --- a/gitnexus/test/unit/uninstall.test.ts +++ b/gitnexus/test/unit/uninstall.test.ts @@ -212,22 +212,46 @@ describe('uninstallCommand', () => { const activeHome = path.join(tempHome, 'active-codex'); const activeConfig = path.join(activeHome, 'config.toml'); const defaultConfig = path.join(tempHome, '.codex', 'config.toml'); + const activeHooks = path.join(activeHome, 'hooks.json'); + const defaultHooks = path.join(tempHome, '.codex', 'hooks.json'); + const activeScriptDir = path.join(activeHome, 'hooks', 'gitnexus'); + const defaultScriptDir = path.join(tempHome, '.codex', 'hooks', 'gitnexus'); const activeRaw = '[mcp_servers.gitnexus]\ncommand = "active"\n'; const defaultRaw = '[mcp_servers.gitnexus]\ncommand = "default"\n'; + const hooksRaw = JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: 'Bash', hooks: [{ type: 'command', command: 'node gitnexus-hook.cjs' }] }, + { matcher: 'Read', hooks: [{ type: 'command', command: 'user-hook' }] }, + ], + }, + }); await fs.mkdir(activeHome); await fs.mkdir(path.dirname(defaultConfig)); + await fs.mkdir(activeScriptDir, { recursive: true }); + await fs.mkdir(defaultScriptDir, { recursive: true }); await fs.writeFile(activeConfig, activeRaw); await fs.writeFile(defaultConfig, defaultRaw); + await fs.writeFile(activeHooks, hooksRaw); + await fs.writeFile(defaultHooks, hooksRaw); process.env.CODEX_HOME = activeHome; const uninstallCommand = await importUninstall(); await uninstallCommand(); expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain(activeConfig); + expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain(activeHooks); expect(await fs.readFile(activeConfig, 'utf-8')).toBe(activeRaw); + expect(await fs.readFile(activeHooks, 'utf-8')).toBe(hooksRaw); await uninstallCommand({ force: true }); expect(await fs.readFile(activeConfig, 'utf-8')).not.toContain('[mcp_servers.gitnexus]'); expect(await fs.readFile(defaultConfig, 'utf-8')).toBe(defaultRaw); + const remaining = JSON.parse(await fs.readFile(activeHooks, 'utf-8')); + expect(remaining.hooks.PreToolUse).toHaveLength(1); + expect(remaining.hooks.PreToolUse[0].hooks[0].command).toBe('user-hook'); + expect(await fs.readFile(defaultHooks, 'utf-8')).toBe(hooksRaw); + await expect(fs.access(activeScriptDir)).rejects.toThrow(); + await expect(fs.access(defaultScriptDir)).resolves.toBeUndefined(); }); it('leaves a corrupt JSON config untouched', async () => {