feat: full Codex support — hooks, plugin marketplace, and setup (#2328, supersedes #1131) (#2369)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / ci (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run

* feat(setup): install Codex PreToolUse/PostToolUse hooks (#2328)

Codex CLI supports lifecycle hooks with Claude Code's exact
{hooks: {Event: [...]}} JSON schema, stdin payload, and
hookSpecificOutput response contract, registered in a dedicated
~/.codex/hooks.json (https://developers.openai.com/codex/hooks).

Parameterize installClaudeCodeHooks into installClaudeSchemaHooks
(claude | codex): both runtimes share the installer, the bundled
gitnexus-hook.cjs adapter, and its helpers. A codex HookTarget in
editor-targets.ts makes uninstall and the setup-uninstall round-trip
tripwire cover the new surface with no uninstall.ts changes.

SessionStart is deliberately not registered: Codex reads AGENTS.md
natively, which already carries the GitNexus context block.

Closes #2328. Closes #244 (Codex setup support is now complete:
MCP + skills + hooks).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(plugin): make the GitNexus plugin installable from Codex (#1131)

Codex's plugin system (https://developers.openai.com/codex/plugins/build)
reads a .codex-plugin/plugin.json manifest and a repo-root
.agents/plugins/marketplace.json registry. The existing
gitnexus-claude-plugin/ is already Codex-compatible as-is — Codex sets
CLAUDE_PLUGIN_ROOT for hook-command compatibility, loads the same
SKILL.md skills, hooks/hooks.json, and .mcp.json — so a second manifest
in the same folder replaces PR #1131's duplicated plugin tree with zero
copied skills or hooks. The .gitignore .agents/ scratch rule narrows to
re-include only the registry file.

Install: codex plugin marketplace add abhigyanpatwari/GitNexus

Supersedes #1131.

Co-authored-by: jublin <1799126+jublin@users.noreply.github.com>

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: document Codex full support (MCP + skills + hooks + plugin)

Promote Codex to Full in both editor tables, document the
~/.codex/hooks.json hook install, and add the Codex plugin
marketplace install path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(release): extend the version-lockstep guard to the Codex manifests

The always-on drift guard asserted only the Claude plugin manifests
against gitnexus/package.json, so a release could ship stale versions in
.codex-plugin/plugin.json and .agents/plugins/marketplace.json without
CI noticing. Mirror the Claude lockstep test for the two Codex files and
extend the CONTRIBUTING §Releases lockstep list to match.

Verified guard semantics: a deliberate local version mutation of the
Codex marketplace entry turns the new test red.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugin): quote the hook command path for space-containing plugin roots

Both plugin hook commands ran `node ${CLAUDE_PLUGIN_ROOT}/hooks/...`
unquoted, which breaks whenever the substituted plugin root contains a
space — the common case on Windows user profiles. Both Claude Code and
Codex substitute the placeholder before shell execution, and Claude
Code's plugin docs mandate the double-quoted form in shell-form hooks.

No commandWindows entry: Codex source (codex-rs hooks engine) falls back
to `command` on Windows with identical placeholder substitution, so an
identical-content override would be pure duplication.

Verified: space-in-root smoke test (old form exits 1 MODULE_NOT_FOUND,
quoted form exits 0), `claude plugin validate` passes, and a local
`codex plugin marketplace add` parses the marketplace + plugin cleanly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(setup): pin fail-closed behavior for unreadable/corrupt Codex hooks.json

The non-ENOENT suite covered Claude settings.json (EACCES) and Codex
config.toml (EACCES) but not the new ~/.codex/hooks.json surface, and the
mergeHooksJsonc "is corrupt" branch had zero coverage for either editor.
A future refactor dropping the isEnoent rethrow or the parse gate could
silently rewrite a user's hooks.json gitnexus-only with no CI tripwire.

Two regression tests: EACCES leaves hooks.json byte-identical and reports
"Codex hooks: EACCES"; corrupt content is preserved and reported via
"Codex hooks: hooks.json is corrupt".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(readme): add the Codex plugin-marketplace install path to the npm README

The root README documents the one-step plugin route but the package
README (what npmjs.com renders) only showed the setup-CLI path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(setup): rename claudeHook to hookCfg in installClaudeSchemaHooks

The local held a codex HookTarget on the codex branch since the installer
was parameterized, so the claude-specific name misled. Pure local rename,
no behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(readme): document Codex SessionStart exclusion, /hooks trust gate, and install-route choice

Three behaviors were only recorded in code comments and the PR body:
SessionStart is deliberately not registered (Codex reads AGENTS.md
natively), setup-installed hooks need one-time /hooks approval in Codex,
and the setup CLI and plugin are alternative install routes whose hooks
load alongside each other if both are used.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(test): share one logLines helper across setup.test.ts describes

The corrupt-hooks.json test inlined the console.log-flattening
expression that the non-ENOENT describe already defined locally. Hoist a
single file-scope logLines so the two stay in sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Gergő Magyar 2026-07-04 13:32:17 +01:00 committed by GitHub
parent 6252aa745f
commit 187c162fd8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 285 additions and 47 deletions

View file

@ -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"
}
]
}

7
.gitignore vendored
View file

@ -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/

View file

@ -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

View file

@ -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.
<a id="fn-antigravity-hooks"></a>
> ¹ **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

View file

@ -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"]
}

View file

@ -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..."
}

View file

@ -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):

View file

@ -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',

View file

@ -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<void> {
const claudeDir = path.join(os.homedir(), '.claude');
if (!(await dirExists(claudeDir))) return;
async function installClaudeSchemaHooks(
result: SetupResult,
id: 'claude' | 'codex',
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
},
});
}
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<void> {
}
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) {

View file

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

View file

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