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)', () => {