diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 000000000..6494fac53 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,21 @@ +{ + "name": "gitnexus-marketplace", + "interface": { + "displayName": "GitNexus" + }, + "plugins": [ + { + "name": "gitnexus", + "version": "1.6.9", + "source": { + "source": "local", + "path": "./gitnexus-claude-plugin" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Developer Tools" + } + ] +} diff --git a/.gitignore b/.gitignore index 11f2743c7..63c8bdc4c 100644 --- a/.gitignore +++ b/.gitignore @@ -106,7 +106,12 @@ gitnexus/vendor/**/node_modules/ local_docs/ # Local agent scratch / review prompts (never commit) +# (.agents/plugins/marketplace.json is the checked-in Codex plugin +# marketplace registry — the rest of .agents/ stays local scratch.) .tmp/ -.agents/ +.agents/* +!.agents/plugins/ +.agents/plugins/* +!.agents/plugins/marketplace.json .context/ gitnexus/web/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 278dd72d2..e03f446ab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -169,7 +169,9 @@ routes between two modes based on the triggering event: not enforce branch reachability. No Docker build (RC-only). Before cutting a stable release, keep `gitnexus/package.json`, `gitnexus-claude-plugin/.claude-plugin/plugin.json`, - `.claude-plugin/marketplace.json`, and the matching `CHANGELOG.md` entry in + `.claude-plugin/marketplace.json`, + `gitnexus-claude-plugin/.codex-plugin/plugin.json`, + `.agents/plugins/marketplace.json`, and the matching `CHANGELOG.md` entry in lockstep — the always-on `gitnexus` unit suite now fails if those manifest versions drift. - **Release-candidate mode** — runs on every push to `main` (typically a diff --git a/README.md b/README.md index 6b7cadf8f..4ea40d510 100644 --- a/README.md +++ b/README.md @@ -199,13 +199,13 @@ flowchart TB | **Claude Code** | Yes | Yes | Yes (PreToolUse + PostToolUse) | **Full** | | **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](gitnexus-cursor-integration/README.md#hook-install)) | **Full** | | **Antigravity** (Google) | Yes | Yes | Yes (AfterTool, [Gemini CLI hooks schema](https://geminicli.com/docs/hooks/reference/))[¹](#fn-antigravity-hooks) | **Full** | -| **Codex** | Yes | Yes | — | MCP + Skills | +| **Codex** | Yes | Yes | Yes (PreToolUse + PostToolUse, [Codex hooks](https://developers.openai.com/codex/hooks)) | **Full** | | **OpenCode** | Yes | Yes | — | MCP + Skills | | **CodeBuddy** (Tencent) | Yes | Yes | — | MCP + Skills | | **Qoder** (Alibaba) | Yes | Yes | — | MCP + Skills | | **Windsurf** | Yes | — | — | MCP | -> **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that enrich searches with graph context + PostToolUse hooks that detect a stale index after commits and prompt the agent to reindex. +> **Claude Code** and **Codex** get the deepest integration: MCP tools + agent skills + PreToolUse hooks that enrich searches with graph context + PostToolUse hooks that detect a stale index after commits and prompt the agent to reindex. > ¹ **Antigravity hooks** follow the [Gemini CLI hooks reference](https://geminicli.com/docs/hooks/reference/) (Antigravity 2.0 is the documented successor to Gemini CLI). Augmentation runs in `AfterTool` because `BeforeTool` has no context-injection channel in the Gemini contract — the agent sees graph context appended to the tool result via `hookSpecificOutput.additionalContext`. Stale-index hints land in the same channel after a successful `git commit/merge/rebase/cherry-pick/pull`. The schema may evolve if Antigravity-specific hook docs diverge from Gemini CLI's; the implementation will track those changes. @@ -223,7 +223,7 @@ claude mcp add gitnexus -- npx -y gitnexus@latest mcp claude mcp add gitnexus -- cmd /c npx -y gitnexus@latest mcp ``` -**Codex** (MCP + skills): +**Codex** (full support — MCP + skills + hooks): ```bash codex mcp add gitnexus -- npx -y gitnexus@latest mcp @@ -237,6 +237,17 @@ command = "npx" args = ["-y", "gitnexus@latest", "mcp"] ``` +Codex hooks (PreToolUse graph enrichment + PostToolUse stale-index detection in `~/.codex/hooks.json`, [same schema as Claude Code](https://developers.openai.com/codex/hooks)) need the bundled adapter script, so they are installed by `gitnexus setup -c codex` rather than manually. + +Alternatively, install everything as a [Codex plugin](https://developers.openai.com/codex/plugins/build) (MCP + skills + hooks in one step): + +```bash +codex plugin marketplace add abhigyanpatwari/GitNexus +# then inside Codex: /plugins → install "GitNexus" +``` + +> **Codex notes:** SessionStart is intentionally not registered — Codex reads [AGENTS.md natively](https://developers.openai.com/codex/guides/agents-md), which already carries the GitNexus context block. Newly installed hooks need a one-time approval in Codex via `/hooks` before they run. Pick **one** install route (`gitnexus setup -c codex` **or** the plugin): plugin hooks load alongside `~/.codex/hooks.json`, so installing both can fire duplicate hooks per tool call. + **Cursor** (`~/.cursor/mcp.json` — global, works for all projects): ```json diff --git a/gitnexus-claude-plugin/.codex-plugin/plugin.json b/gitnexus-claude-plugin/.codex-plugin/plugin.json new file mode 100644 index 000000000..c9a03db4d --- /dev/null +++ b/gitnexus-claude-plugin/.codex-plugin/plugin.json @@ -0,0 +1,25 @@ +{ + "name": "gitnexus", + "description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase.", + "version": "1.6.9", + "skills": "./skills", + "mcpServers": "./.mcp.json", + "hooks": "./hooks/hooks.json", + "interface": { + "displayName": "GitNexus", + "category": "Developer Tools", + "capabilities": [ + "code-exploration", + "impact-analysis", + "debugging", + "refactoring", + "code-review" + ] + }, + "author": { + "name": "GitNexus" + }, + "homepage": "https://github.com/abhigyanpatwari/GitNexus", + "repository": "https://github.com/abhigyanpatwari/GitNexus", + "keywords": ["code-intelligence", "knowledge-graph", "mcp", "static-analysis"] +} diff --git a/gitnexus-claude-plugin/hooks/hooks.json b/gitnexus-claude-plugin/hooks/hooks.json index f9ed9f84a..fbfb247db 100644 --- a/gitnexus-claude-plugin/hooks/hooks.json +++ b/gitnexus-claude-plugin/hooks/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/gitnexus-hook.js", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gitnexus-hook.js\"", "timeout": 10, "statusMessage": "Enriching with GitNexus graph context..." } @@ -19,7 +19,7 @@ "hooks": [ { "type": "command", - "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/gitnexus-hook.js", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gitnexus-hook.js\"", "timeout": 10, "statusMessage": "Checking GitNexus index freshness..." } diff --git a/gitnexus/README.md b/gitnexus/README.md index e82bf87be..5c7e11919 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -43,13 +43,13 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up | **Claude Code** | Yes | Yes | Yes (PreToolUse + PostToolUse) | **Full** | | **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](../gitnexus-cursor-integration/README.md#hook-install)) | **Full** | | **Antigravity** (Google) | Yes | Yes | Yes (AfterTool, [Gemini CLI hooks schema](https://geminicli.com/docs/hooks/reference/)) | **Full** | -| **Codex** | Yes | Yes | — | MCP + Skills | +| **Codex** | Yes | Yes | Yes (PreToolUse + PostToolUse, [Codex hooks](https://developers.openai.com/codex/hooks)) | **Full** | | **OpenCode** | Yes | Yes | — | MCP + Skills | | **CodeBuddy** (Tencent) | Yes | Yes | — | MCP + Skills | | **Qoder** (Alibaba) | Yes | Yes | — | MCP + Skills | | **Windsurf** | Yes | — | — | MCP | -> **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that automatically enrich grep/glob/bash calls with knowledge graph context + PostToolUse hooks that detect a stale index after commits and prompt the agent to reindex. +> **Claude Code** and **Codex** get the deepest integration: MCP tools + agent skills + PreToolUse hooks that automatically enrich grep/glob/bash calls with knowledge graph context + PostToolUse hooks that detect a stale index after commits and prompt the agent to reindex. ### Community Integrations @@ -71,12 +71,23 @@ claude mcp add gitnexus -- npx -y gitnexus@latest mcp claude mcp add gitnexus -- cmd /c npx -y gitnexus@latest mcp ``` -### Codex (full support — MCP + skills) +### Codex (full support — MCP + skills + hooks) ```bash codex mcp add gitnexus -- npx -y gitnexus@latest mcp ``` +Codex hooks (PreToolUse graph enrichment + PostToolUse stale-index detection in `~/.codex/hooks.json`, [same schema as Claude Code](https://developers.openai.com/codex/hooks)) need the bundled adapter script, so they are installed by `gitnexus setup -c codex` rather than manually. + +Alternatively, install everything as a [Codex plugin](https://developers.openai.com/codex/plugins/build) (MCP + skills + hooks in one step): + +```bash +codex plugin marketplace add abhigyanpatwari/GitNexus +# then inside Codex: /plugins → install "GitNexus" +``` + +> **Codex notes:** SessionStart is intentionally not registered — Codex reads [AGENTS.md natively](https://developers.openai.com/codex/guides/agents-md), which already carries the GitNexus context block. Newly installed hooks need a one-time approval in Codex via `/hooks` before they run. Pick **one** install route (`gitnexus setup -c codex` **or** the plugin): plugin hooks load alongside `~/.codex/hooks.json`, so installing both can fire duplicate hooks per tool call. + ### Cursor / Windsurf Add to `~/.cursor/mcp.json` (global — works for all projects): diff --git a/gitnexus/src/cli/editor-targets.ts b/gitnexus/src/cli/editor-targets.ts index 8429f98ad..a4c511fa0 100644 --- a/gitnexus/src/cli/editor-targets.ts +++ b/gitnexus/src/cli/editor-targets.ts @@ -180,6 +180,17 @@ export function getEditorTargets(home: string = os.homedir()): EditorTargets { needle: 'gitnexus-hook', scriptDir: path.join(home, '.claude', 'hooks', 'gitnexus'), }, + { + id: 'codex', + label: 'Codex', + // 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'), + events: ['PreToolUse', 'PostToolUse'], + needle: 'gitnexus-hook', + scriptDir: path.join(home, '.codex', 'hooks', 'gitnexus'), + }, { id: 'antigravity', label: 'Antigravity', diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index de1edc002..3088ec3da 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -487,22 +487,31 @@ export async function copyHookHelpers( } /** - * Install GitNexus hooks to ~/.claude/settings.json for Claude Code. - * Merges hook config without overwriting existing hooks, preserving - * comments and formatting in the JSONC file. + * Install GitNexus hooks for editors that use Claude Code's hooks schema. + * + * Claude Code registers hooks in ~/.claude/settings.json; Codex uses a + * dedicated ~/.codex/hooks.json with the identical {hooks: {Event: [...]}} + * JSON shape, stdin payload, and hookSpecificOutput response contract + * (https://developers.openai.com/codex/hooks), so both runtimes share this + * installer and the same bundled adapter script. Merges hook config without + * overwriting existing hooks, preserving comments and formatting. */ -async function installClaudeCodeHooks(result: SetupResult): Promise { - const claudeDir = path.join(os.homedir(), '.claude'); - if (!(await dirExists(claudeDir))) return; +async function installClaudeSchemaHooks( + result: SetupResult, + id: 'claude' | 'codex', +): Promise { + const hookCfg = hookTarget(id); + const settingsPath = hookCfg.settingsFile; + const label = `${hookCfg.label} hooks`; - const claudeHook = hookTarget('claude'); - const settingsPath = claudeHook.settingsFile; + // Gate on the editor's own config dir (~/.claude, ~/.codex) existing. + if (!(await dirExists(path.dirname(settingsPath)))) return; // Source hooks bundled within the gitnexus package (hooks/claude/) const pluginHooksPath = path.join(__dirname, '..', '..', 'hooks', 'claude'); - // Copy unified hook script to ~/.claude/hooks/gitnexus/ - const destHooksDir = claudeHook.scriptDir; + // Copy unified hook script to the editor's hooks/gitnexus/ dir + const destHooksDir = hookCfg.scriptDir; try { await fs.mkdir(destHooksDir, { recursive: true }); @@ -516,7 +525,7 @@ async function installClaudeCodeHooks(result: SetupResult): Promise { const jsonCli = JSON.stringify(normalizedCli); if (!content.includes(CLI_PATH_SOURCE_LITERAL)) { result.errors.push( - 'Claude Code hooks: gitnexus-hook.cjs no longer contains the cliPath literal to patch — the installed hook may fail to resolve the CLI. Update CLI_PATH_SOURCE_LITERAL in setup.ts.', + `${label}: gitnexus-hook.cjs no longer contains the cliPath literal to patch — the installed hook may fail to resolve the CLI. Update CLI_PATH_SOURCE_LITERAL in setup.ts.`, ); } content = content.replace(CLI_PATH_SOURCE_LITERAL, `let cliPath = ${jsonCli};`); @@ -531,21 +540,14 @@ async function installClaudeCodeHooks(result: SetupResult): Promise { try { await fs.access(dest); } catch { - result.errors.push( - 'Claude Code hooks: adapter script was not installed — skipping hook registration', - ); + result.errors.push(`${label}: adapter script was not installed — skipping hook registration`); return; } - const failedRequired = await copyHookHelpers( - pluginHooksPath, - destHooksDir, - 'Claude Code hooks', - result, - ); + const failedRequired = await copyHookHelpers(pluginHooksPath, destHooksDir, label, result); if (failedRequired.length > 0) { result.errors.push( - `Claude Code hooks: required helper(s) ${failedRequired.join(', ')} failed to copy — skipping hook registration`, + `${label}: required helper(s) ${failedRequired.join(', ')} failed to copy — skipping hook registration`, ); return; } @@ -565,10 +567,11 @@ async function installClaudeCodeHooks(result: SetupResult): Promise { const hookEntries: Array<{ eventName: string; value: unknown }> = []; - // NOTE: SessionStart hooks are broken on Windows (Claude Code bug #23576). - // Session context is delivered via CLAUDE.md / skills instead. + // NOTE: SessionStart hooks are broken on Windows (Claude Code bug #23576), + // and Codex reads AGENTS.md natively. Session context is delivered via + // CLAUDE.md / AGENTS.md / skills instead. - if (!hasGitnexusHook(parsed?.hooks, 'PreToolUse', claudeHook.needle)) { + if (!hasGitnexusHook(parsed?.hooks, 'PreToolUse', hookCfg.needle)) { hookEntries.push({ eventName: 'PreToolUse', value: { @@ -584,7 +587,7 @@ async function installClaudeCodeHooks(result: SetupResult): Promise { }, }); } - if (!hasGitnexusHook(parsed?.hooks, 'PostToolUse', claudeHook.needle)) { + if (!hasGitnexusHook(parsed?.hooks, 'PostToolUse', hookCfg.needle)) { hookEntries.push({ eventName: 'PostToolUse', value: { @@ -602,20 +605,20 @@ async function installClaudeCodeHooks(result: SetupResult): Promise { } if (hookEntries.length === 0) { - result.configured.push('Claude Code hooks (already configured)'); + result.configured.push(`${label} (already configured)`); return; } const ok = await mergeHooksJsonc(settingsPath, hookEntries); if (ok) { - result.configured.push('Claude Code hooks (PreToolUse, PostToolUse)'); + result.configured.push(`${label} (PreToolUse, PostToolUse)`); } else { result.errors.push( - 'Claude Code hooks: settings.json is corrupt — skipping to preserve existing content', + `${label}: ${path.basename(settingsPath)} is corrupt — skipping to preserve existing content`, ); } } catch (err: any) { - result.errors.push(`Claude Code hooks: ${err.message}`); + result.errors.push(`${label}: ${err.message}`); } } @@ -1198,7 +1201,7 @@ export const setupCommand = async (options?: { codingAgent?: string[] | string } // Install global skills for platforms that support them if (selected.has('claude')) { await installClaudeCodeSkills(result); - await installClaudeCodeHooks(result); + await installClaudeSchemaHooks(result, 'claude'); } if (selected.has('antigravity')) { await installAntigravitySkills(result); @@ -1208,7 +1211,10 @@ export const setupCommand = async (options?: { codingAgent?: string[] | string } if (selected.has('opencode')) await installOpenCodeSkills(result); if (selected.has('codebuddy')) await installCodeBuddySkills(result); if (selected.has('qoder')) await installQoderSkills(result); - if (selected.has('codex')) await installCodexSkills(result); + if (selected.has('codex')) { + await installCodexSkills(result); + await installClaudeSchemaHooks(result, 'codex'); + } // Print results if (result.configured.length > 0) { diff --git a/gitnexus/test/unit/cli-commands.test.ts b/gitnexus/test/unit/cli-commands.test.ts index beb1eae96..545a38b7f 100644 --- a/gitnexus/test/unit/cli-commands.test.ts +++ b/gitnexus/test/unit/cli-commands.test.ts @@ -49,6 +49,26 @@ describe('CLI commands', () => { expect(pluginManifest.version).toBe(pkg.default.version); expect(gitnexusEntries[0]?.version).toBe(pkg.default.version); }); + + it('keeps Codex plugin manifests aligned with the gitnexus release version', async () => { + const pkg = await import('../../package.json', { with: { type: 'json' } }); + const pluginManifest = await readRepoJson<{ version: string }>( + 'gitnexus-claude-plugin/.codex-plugin/plugin.json', + ); + const marketplaceManifest = await readRepoJson<{ + plugins?: Array<{ name: string; version: string }>; + }>('.agents/plugins/marketplace.json'); + + expect(Array.isArray(marketplaceManifest.plugins)).toBe(true); + + const gitnexusEntries = (marketplaceManifest.plugins ?? []).filter( + (plugin) => plugin.name === 'gitnexus', + ); + + expect(gitnexusEntries).toHaveLength(1); + expect(pluginManifest.version).toBe(pkg.default.version); + expect(gitnexusEntries[0]?.version).toBe(pkg.default.version); + }); }); describe('package.json scripts', () => { diff --git a/gitnexus/test/unit/setup.test.ts b/gitnexus/test/unit/setup.test.ts index d434b4a99..448074669 100644 --- a/gitnexus/test/unit/setup.test.ts +++ b/gitnexus/test/unit/setup.test.ts @@ -10,6 +10,13 @@ const PKG_VERSION = (createRequire(import.meta.url)('../../package.json') as { v .version; const MCP_PINNED_REF = `gitnexus@${PKG_VERSION}`; +/** Flatten the spied console.log calls into one searchable string. */ +const logLines = () => + vi + .mocked(console.log) + .mock.calls.map((call) => call.join(' ')) + .join('\n'); + const execFileMock = vi.fn((...args: any[]) => { const callback = args.at(-1); if (typeof callback === 'function') { @@ -708,6 +715,107 @@ describe('setupQoder', () => { }); }); +describe('Codex hooks (installClaudeSchemaHooks)', () => { + let tempHome: string; + let originalHome: string | undefined; + let originalUserProfile: string | undefined; + + const hooksJsonPath = () => path.join(tempHome, '.codex', 'hooks.json'); + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + + originalHome = process.env.HOME; + originalUserProfile = process.env.USERPROFILE; + tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-codex-hooks-')); + process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; + + // Only create ~/.codex — no other editor directories so their + // setup functions skip and don't pollute assertions. + await fs.mkdir(path.join(tempHome, '.codex'), { recursive: true }); + + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + process.env.HOME = originalHome; + process.env.USERPROFILE = originalUserProfile; + await fs.rm(tempHome, { recursive: true, force: true }); + }); + + it('registers PreToolUse + PostToolUse in ~/.codex/hooks.json and installs the adapter', async () => { + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const hooks = JSON.parse(await fs.readFile(hooksJsonPath(), 'utf-8')).hooks; + expect(hooks).toMatchObject({ + PreToolUse: [{ matcher: 'Grep|Glob|Bash' }], + PostToolUse: [{ matcher: 'Bash' }], + }); + for (const event of ['PreToolUse', 'PostToolUse']) { + expect(hooks[event][0].hooks[0].command).toContain('gitnexus-hook'); + } + await expect( + fs.access(path.join(tempHome, '.codex', 'hooks', 'gitnexus', 'gitnexus-hook.cjs')), + ).resolves.toBeUndefined(); + }); + + it('is idempotent — a second setup run adds no duplicate entries', async () => { + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + await setupCommand(); + + const hooks = JSON.parse(await fs.readFile(hooksJsonPath(), 'utf-8')).hooks; + expect(hooks.PreToolUse).toHaveLength(1); + expect(hooks.PostToolUse).toHaveLength(1); + }); + + it('preserves a user-owned hook already present in hooks.json', async () => { + await fs.writeFile( + hooksJsonPath(), + JSON.stringify({ + hooks: { + PreToolUse: [{ matcher: 'Read', hooks: [{ type: 'command', command: 'my-own-hook' }] }], + }, + }), + 'utf-8', + ); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const hooks = JSON.parse(await fs.readFile(hooksJsonPath(), 'utf-8')).hooks; + const commands: string[] = hooks.PreToolUse.flatMap((e: { hooks: { command: string }[] }) => + e.hooks.map((h) => h.command), + ); + expect(commands).toContain('my-own-hook'); + expect(commands.some((c: string) => c.includes('gitnexus-hook'))).toBe(true); + }); + + it('does not write hooks.json when ~/.codex is absent', async () => { + await fs.rm(path.join(tempHome, '.codex'), { recursive: true, force: true }); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + await expect(fs.access(hooksJsonPath())).rejects.toThrow(); + }); + + it('leaves a corrupt hooks.json untouched and reports it (fail closed)', async () => { + const corrupt = '{ this is not valid json !!!'; + await fs.writeFile(hooksJsonPath(), corrupt, 'utf-8'); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + expect(await fs.readFile(hooksJsonPath(), 'utf-8')).toBe(corrupt); + expect(logLines()).toContain('Codex hooks: hooks.json is corrupt'); + }); +}); + describe('setup — non-ENOENT read/stat failures are surfaced, not masked', () => { let tempHome: string; let originalHome: string | undefined; @@ -716,12 +824,6 @@ describe('setup — non-ENOENT read/stat failures are surfaced, not masked', () const errnoError = (code: string) => Object.assign(new Error(`${code}: simulated failure`), { code }); - const logLines = () => - vi - .mocked(console.log) - .mock.calls.map((call) => call.join(' ')) - .join('\n'); - beforeEach(async () => { vi.resetModules(); vi.clearAllMocks(); @@ -840,6 +942,30 @@ describe('setup — non-ENOENT read/stat failures are surfaced, not masked', () expect(await fs.readFile(configPath, 'utf-8')).toBe(raw); expect(logLines()).toContain('Codex: EACCES'); }); + + it('does not rewrite an unreadable ~/.codex/hooks.json as hooks-only (fail closed)', async () => { + await fs.mkdir(path.join(tempHome, '.codex'), { recursive: true }); + const hooksPath = path.join(tempHome, '.codex', 'hooks.json'); + const raw = JSON.stringify({ + hooks: { PreToolUse: [{ matcher: 'Read', hooks: [{ type: 'command', command: 'mine' }] }] }, + }); + await fs.writeFile(hooksPath, raw, 'utf-8'); + + const realReadFile = fs.readFile; + vi.spyOn(fs, 'readFile').mockImplementation(((file: any, ...rest: any[]) => { + if (String(file) === hooksPath) return Promise.reject(errnoError('EACCES')); + return (realReadFile as any)(file, ...rest); + }) as typeof fs.readFile); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + vi.mocked(fs.readFile).mockRestore(); + // The user's hooks survive; the installer reports instead of replacing + // the whole file with a gitnexus-only document. + expect(await fs.readFile(hooksPath, 'utf-8')).toBe(raw); + expect(logLines()).toContain('Codex hooks: EACCES'); + }); }); describe('formatHookCommand (hook command escaping, #1945)', () => {