fix(cli): steer docs, skills, and hooks through a CLI-neutral project-local runner (#1939) (#1945)

* fix(cli): steer npm 11 users away from npx install crash (#1939)

Prefer global gitnexus or pnpm dlx in hooks and generated AI context, warn
when npm 11.x would use the broken npx path, and document workarounds for
the arborist node.target null failure mode.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(hooks): stage resolve-analyze-cmd.cjs for antigravity adapter; harden load checks

The antigravity adapter gained a top-level require('./resolve-analyze-cmd.cjs')
but stageAdapter() did not copy it, so the spawned adapter crashed with
MODULE_NOT_FOUND. Three load-sensitive tests failed; four silent-path tests
false-passed on empty stdout.

Stage the helper alongside the other sibling helpers, and assert status===0 and
no MODULE_NOT_FOUND on the four silent-path tests so a non-loading hook can never
pass green again. Force a deterministic invocation mode in the stale-index test
so the emitted analyze command no longer varies by CI-runner PATH.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): standardize invocation hints on gitnexus@latest; single-source CJS helper

NPX_REF becomes a literal `gitnexus@latest` in resolve-invocation.ts, dropping
the package.json require and the module-load throw (a malformed/absent version
can no longer crash any CLI command at import). The safety this PR delivers is
the install method steered to (global / pnpm dlx), not a pinned gitnexus
version, and the in-repo CJS mirror already degraded to `latest` once copied
outside the package.

Make the two resolve-analyze-cmd.cjs copies byte-identical and add a parity
test that fails on drift. The separate, version-pinned NPX_REF that setup.ts
writes into the MCP server registration is intentional and left unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>

* perf(cli): move npm-11 npx warning off module load; memoize invocation mode

warnIfNpm11NpxRisk() ran at index.ts module load, so every CLI invocation
(including the `gitnexus mcp` stdio hot path) paid which/where + npm --version
spawns — against the lazy-startup/MCP-stdout discipline (#207, #1383). Move the
call into analyzeCommand, after the ensureHeap() re-exec guard, so it fires once
in the working process and only for `analyze`.

Memoize the PATH-probe-derived invocation mode (the GITNEXUS_INVOCATION override
stays uncached) so repeated callers don't re-probe, and add a test-only reset so
the cache + once-only warning flag don't leak across the unit suite. Covers the
mode!=='npx', npm<11, and npm-absent suppression branches.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): detect .exe/extensionless global gitnexus shims on Windows

The winGitnexusWrapper branch only matched .cmd/.bat, so a global gitnexus
installed by Volta or scoop (a .exe or an extensionless shim) was missed and the
hint fell back to pnpm/npx. Accept .exe and treat any non-empty `where` hit as
on-PATH (the emitted hint is `gitnexus analyze` regardless of which shim
resolves it). Mirror the change into both resolve-analyze-cmd.cjs copies so the
TS source and the byte-identical hook mirrors stay in sync.

Add Windows-mocked test cases (.exe-only, extensionless, .cmd preference, CRLF
stripping) and register resolve-invocation.test.ts in cross-platform-tests.ts so
the windows-latest runner exercises the branch.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): emit fixed pnpm dlx analyze command in generated AGENTS.md/CLAUDE.md

ai-context baked a machine-resolved command (formatAnalyzeCommand) into
git-tracked AGENTS.md/CLAUDE.md, so the stale-index hint varied per machine and
churned across branches (the #1706 class). Emit the fixed string
`pnpm dlx gitnexus@latest analyze` instead: committed AI-context is the most
authoritative instruction an agent reads, so it must name an install-free,
crash-free method — never `npx`, the npm-11 path #1939 steers away from.

formatAnalyzeCommand stays exported and unit-tested in resolve-invocation.ts
(it still mirrors the two .cjs hook copies); ai-context just no longer calls it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(cli): unify hook-helper copy into one non-silent routine

installClaudeCodeHooks copied its four hook helpers in separate try/catch blocks
that silently swallowed failures, while installAntigravityHooks recorded an
error per failed copy. Extract one copyHookHelpers(srcDir, destDir, label,
result) with a single canonical helper list (including resolve-analyze-cmd.cjs)
and the antigravity loop's error-reporting policy, and use it from both paths so
a missing helper surfaces as a setup error instead of a silent runtime crash.

Assert both the Claude and Antigravity install paths co-locate
resolve-analyze-cmd.cjs next to the adapter, and that a failed copy records an
error rather than passing silently.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(cli): reattach installClaudeCodeHooks JSDoc after helper extraction

The extracted HOOK_HELPERS/copyHookHelpers block landed between the
installClaudeCodeHooks JSDoc and its function, leaving the doc reading as if it
described the helper list. Move the block above the doc so it documents the
function again. No behavior change.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(cli): enforce TS<->CJS invocation parity and guard CLI startup posture

Tier-2 review found two in-scope gaps in the #1945 follow-up:

- The "mirrors resolve-invocation.ts / test enforces parity" comments overclaimed:
  the parity test only compared the two .cjs copies to each other, so the TS
  source and the CJS hook copies could silently drift (NPX_REF, the per-mode
  command, and the Windows shim regex were hand-edited in all three this PR).
  Add TS<->CJS value parity (NPX_REF + formatAnalyzeCommand for every forced
  mode) and a source-level shim-regex parity check, and make the mirror comments
  accurately describe what is enforced.

- No test locked the R3/R4 startup posture, so re-adding warnIfNpm11NpxRisk()
  (or any resolve-invocation import) at index.ts module scope -- the #207/#1383
  lazy-startup regression -- would pass CI. Add a guard asserting index.ts has
  no module-load invocation probe and the warning is wired into analyzeCommand.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(cli): collapse npx-invocation resolver to one source of truth

PR #1945 carried the gitnexus/pnpm/npx selection in three hand-synced
places — the canonical hook helper, its byte-identical plugin copy, and a
full TypeScript re-implementation in resolve-invocation.ts — kept in lockstep
by per-mode-command and regex-extracted-by-regex parity tests. The TS
formatAnalyzeCommand had no production caller (ai-context emits a fixed
string), and the module memoized + exposed a test-only reset for a "repeated
callers" case that has exactly one caller.

Make hooks/claude/resolve-analyze-cmd.cjs the single source: extract the
Windows-shim line-picking into a pure, exported pickPathMatch() and add an
injectable probe to resolveInvocationMode() so the shipped logic is testable
without spawning or global mocks. resolve-invocation.ts (118 -> 59 lines) now
consumes that cjs via createRequire for resolveInvocationMode/NPX_REF and adds
only the CLI-only npm-version probe and warning; the relative path resolves
identically from src/cli/ (tsx, vitest) and dist/cli/ (shipped, hooks/ is a
published sibling of dist/). Tests exercise the real shipped artifact, the
NPX_REF/mode-command parity scaffolding is dropped (one implementation can't
drift), and parity narrows to the two cjs copies staying byte-identical.

No behavior change: hook stale-index hints and the analyze warning are
byte-identical; the pre-existing setup.ts resolveGitnexusBin is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): bound stale-index hook PATH probe under the hook budget (U1)

The PostToolUse stale-index hint calls formatAnalyzeCommand(), which probes which/where; named PROBE_TIMEOUT_MS=2000 keeps git rev-parse (~3s) + up to two probes well under Claude Code's 10s hook timeout while preserving the machine-correct hint. Byte-identical in the plugin copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): steer generated cross-repo group commands off npx (#1939) (U2)

The Cross-Repo Groups block in generated AGENTS.md/CLAUDE.md still emitted bare 'npx gitnexus group ...', funneling npm-11 users into the arborist crash; switch to fixed 'pnpm dlx gitnexus@latest group ...'. Export generateGitNexusContent and add a group-branch test asserting no 'npx gitnexus' literal survives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: align steering guidance on pnpm dlx gitnexus@latest (U3)

README troubleshooting uses gitnexus@latest; the repo's own committed CLAUDE.md/AGENTS.md stale-index hint now matches the generated output (pnpm dlx gitnexus@latest analyze) so the repo dogfoods the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(hooks): assert exact @latest analyze command and pin invocation mode (U4)

Drop dead PKG_VERSION/NPX_REF version-pinned constants; the cjs always emits gitnexus@latest, so assert exact toContain(...) instead of the /@\\S+/ wildcard; pin GITNEXUS_INVOCATION in the --embeddings tests for host-independent determinism.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(cli): cover resolver warn/edge branches; document probe seam (U5)

Add coverage for the gitnexus-mode warn suppression, getNpmMajorVersion edge inputs (empty/pre-release/non-numeric), and the Windows non-wrapper pickPathMatch branch; widen the InvocationResolver interface to document the optional probe param.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): lower hook PATH-probe timeout to 1000ms (U1)

In a linked worktree the stale-index hook runs git rev-parse --git-common-dir (~2s) + rev-parse HEAD (~3s) before up to two PATH probes; PROBE_TIMEOUT_MS=1000 holds the worst case near ~7s under Claude Code's 10s hook budget (was 2000, ~1s headroom). Byte-identical in the plugin copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): fail closed in gitnexus setup on missing required hook helper/adapter (U2)

copyHookHelpers now returns the failed REQUIRED helpers (the .cjs trio; win-rm-list-json.ps1 stays best-effort since it fails open). Both install paths skip hook registration with an actionable error when a required helper failed; the Claude path also gains the adapter-existence guard the Antigravity path already had. Prevents registering a hook that crashes MODULE_NOT_FOUND on every tool event.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(skills): steer committed skill files off npx to pnpm dlx gitnexus@latest (U3)

All 26 committed skill-file copies (gitnexus/skills, .claude, plugin, cursor) used 'npx gitnexus analyze', contradicting the generated freshness line and funneling npm-11 users into the arborist crash. Replace with 'pnpm dlx gitnexus@latest analyze'; add a regression guard (skills-steering.test.ts) that globs all four locations and fails if any reintroduces it. The cli skill's non-analyze npx subcommands (status/clean/list/wiki) are left as-is (out of the analyze-funnel scope).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): guard resolver import shape; assert group-impact steering (U4)

Add a load-time guard on the createRequire(resolve-analyze-cmd.cjs) cast so a drifted/renamed cjs export fails loudly at module load instead of as a late TypeError in warnIfNpm11NpxRisk. Add the missing 'group impact' assertion to the ai-context Cross-Repo Groups test, and a resolver-contract test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): auto-select invocation path with pnpm --allow-build (#1939)

Probe npm/pnpm versions and PATH to pick a working analyze command without
user configuration: global gitnexus first, pnpm dlx with --allow-build on
npm 11+ (Ladybug native scripts), npx on npm 10 and earlier. Update docs,
skills, and tests to match the canonical install-free command.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): place pnpm --allow-build before dlx, repair version-injection seam (#1939)

The auto-selected install command emitted `pnpm dlx --allow-build=… analyze`,
but pnpm < 10.14 keeps `dlx` in its argv escape list, so flags placed *after*
`dlx` are parsed as package specs and rejected (ERR_PNPM_SPEC_NOT_SUPPORTED) on
pnpm 10.2–10.13.x — strictly worse than the bare command. Move the flags before
`dlx` (the position pnpm has honored since 10.2.0) in both byte-identical hook
copies, the committed AGENTS.md / CLAUDE.md, and every skill tree.

Also repairs the CI-red resolveInvocationMode seam: injecting `{ npmMajor: null }`
to simulate an absent npm fell through `??` to the host's real `npm --version`
(npm 10.x on the CI runners → routed 'npx' instead of 'pnpm'). Use an
`'npmMajor' in deps` sentinel so an injected null is honored, drop the dead
parseMajorVersion guard, and gate the flags on pnpm >= 10.2 via a single
minor-aware probeVersion spawn (skipped for committed docs). Align the TS
getNpmMajorVersion timeout to the 1s hook budget and strengthen the
skills-steering guard with a pre-dlx positive assertion plus a post-dlx
regression check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: add npm-11 pnpm caveat to README Quick Starts (#1939)

The root, package, and cursor-integration README Quick Starts still steered
first-contact users to bare `npx gitnexus analyze` — the exact npm 11.x
arborist install crash issue #1939 names as a funnel. Add a one-line pnpm
`--allow-build … dlx` caveat (keeping the simple npx default for npm<=10 /
pnpm / yarn users); the package README points to its existing npm-11
workaround section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(skills): route every gitnexus-cli command off npx to pnpm dlx (#1939)

The gitnexus-cli skill demonstrated analyze via `pnpm --allow-build … dlx`
but still showed status/clean/wiki/list via bare `npx gitnexus` — the same
package, the same npm-11 crash-prone install path — and its header claimed
"all commands work via npx". Convert every subcommand to the pnpm form across
all three skill copies and reconcile the header. Broaden the skills-steering
guard to forbid any `npx gitnexus` command in the cli-skill copies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(hook): probe pnpm once on the stale-index path (#1939)

The stale-index hook resolved pnpm twice — `which pnpm` for mode selection
then `pnpm --version` for the allow-build gate — two spawns for one tool in a
~9s/10s budget. Capture the version once in formatAnalyzeCommand and thread it
through the existing deps seam (a successful `pnpm --version` proves presence),
sharing a memoized PATH probe with resolveInvocationMode. Add explicit pnpm
10.0-suppress / 10.2-emit boundary tests and relabel the unknown-minor case.
Both byte-identical cjs copies updated together.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(setup): single-quote POSIX hook command + assert cliPath patch applied (#1939)

The hook `command` written into editor settings is shell-evaluated; the
double-quoted `node "<path>"` form left `$`, backtick, and other metacharacters
live in an adversarial $HOME. Single-quote the path on POSIX (Windows keeps the
double-quoted form — those chars are illegal in Windows filenames). Also assert
the cliPath source-literal replace() actually matched, recording an actionable
error on drift instead of silently shipping a hook with an unresolved relative
path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(setup): normalize expected hook path for the Windows runner (#1939)

The new POSIX-escaping test built its expected hook path with path.join,
which emits backslashes on the Windows runner, while setup.ts forward-slash-
normalizes the path before quoting — so `expect(cmd).toBe(node '<path>')`
mismatched on tests/windows-latest. Normalize the expected path the same way.
Production code was already correct; only the test's expected value was
platform-fragile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): steer docs/skills via a project-local runner, not a pnpm default (#1939)

The prior approach hardcoded `pnpm --allow-build=… dlx gitnexus@latest <cmd>`
into every committed skill + the generated AGENTS.md/CLAUDE.md, which assumes
pnpm is installed. Replace it with a CLI-neutral project-local runner:

- `gitnexus analyze` drops `.gitnexus/run.cjs` (a copy of the canonical
  `resolve-analyze-cmd.cjs`, which gains `buildRunnerArgv` + a `require.main`
  exec tail) next to the index. Docs/skills reference `node .gitnexus/run.cjs
  <cmd>`, which auto-selects the runner (global `gitnexus` → `pnpm dlx` → `npx`)
  at call time — no package-manager assumption. README first-run + an inline
  bootstrap note stay universal `npx gitnexus analyze`.
- The exec tail uses `shell` on Windows so `.cmd`/`.ps1`/`.exe` shims resolve
  (execFileSync can't otherwise; Node blocks `.cmd` without a shell,
  CVE-2024-27980), and prints a diagnostic instead of a silent exit 1.

Tests: runner exec-tail (real spawn, exit-code propagation + ENOENT diagnostic),
copy-failure graceful degradation, and per-subcommand routing + pnpm-fallback
vacuity guards. The generated CLAUDE.md block stays under the #856 token budget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): resolve Windows .cmd version probes so pnpm steering fires (#1939)

probeVersion (and the TS getNpmMajorVersion mirror) spawned npm/pnpm
--version via execFileSync with no shell, so on Windows the .cmd shims
ENOENT'd, the probe reported a present tool as absent, and the stale-index
hook recommended the npx crash path #1939 exists to avoid. Add
shell: process.platform === 'win32' to the version probes (the exec tail
already does this). Parse the first version-shaped line so a Corepack/notice
banner on stdout no longer defeats the parse. Carry pnpm presence separately
from version so a present-but-unparseable pnpm still selects pnpm. Drop the
dead probe ?? resolveOnPath coalesce. Cover resolve-analyze-cmd.cjs (+ plugin
twin) with the shell-injection and windowsHide source-regression guards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): widen pnpm allow-build for the --embeddings=N equals form (#1945)

buildRunnerArgv detected embeddings via gitnexusArgs.includes('--embeddings'),
which missed the equals form (--embeddings=5000) that Commander also accepts,
dropping --allow-build=onnxruntime-node on pnpm 10.2+. Match both forms.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(cli): cover the runner exec-tail Windows shell branch on CI (#1945)

runner-exec-tail.test.ts was POSIX-only and unregistered in
cross-platform-tests.ts, so the run.cjs Windows shell:true exec branch ran on
no platform despite the file comment claiming windows-latest covered it. Add a
.cmd-shim it.skipIf(onPosix) case and register the file in SPAWN_CLI so the
windows-latest job runs it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: fix broken troubleshooting anchor in gitnexus README (#1945)

The npm-11 quick-start note linked to #npx-gitnexus-crashes-with-nodetarget-is-null-npm-11,
which matches no heading; the actual troubleshooting heading slugifies to
#cannot-destructure-property-package-of-nodetarget-as-it-is-null. Repoint the link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(hooks): guard resolve-analyze-cmd.cjs in antigravity e2e sanity check (#1945)

The antigravity adapter top-level require()s resolve-analyze-cmd.cjs, but the
beforeAll helper-presence loop did not check for it — a failed copy would
surface as noisy MODULE_NOT_FOUND in downstream tests instead of the intended
actionable 'Helper not installed' error. Add it to the loop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(skills): tie a missing-runner Cannot-find-module error to recovery (#1945)

Generated CLAUDE.md/AGENTS.md make `node .gitnexus/run.cjs` the primary
command, but the runner is gitignored, so a fresh clone or git clean leaves an
agent facing a raw MODULE_NOT_FOUND. The CLAUDE.md block is token-budget-capped
(#856), so the recovery guidance lives in the cli skill (its documented home):
the bootstrap note now names the `Cannot find module` error and points at
`npx gitnexus analyze` to (re)generate the runner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(cli): disambiguate the MCP-pinned ref from the @latest hint (#1945)

setup.ts and resolve-analyze-cmd.cjs both exported a constant named NPX_REF
with different values (version-pinned for the persisted MCP entry vs.
gitnexus@latest for hints). Rename setup.ts's module-private constant to
MCP_PINNED_REF (value and behavior unchanged — the MCP pin stays pinned),
leaving the cjs hint ref and its re-export alone. Also route the createRequire
cast through 'unknown' so it reads as an explicit narrowing to the subset this
module uses rather than a claim about the cjs's full export shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Magyar 2026-06-02 09:00:34 +01:00 • committed by GitHub
parent f4c8d73be4
commit e466ee7451
51 changed files with 1988 additions and 158 deletions

View file

@ -5,14 +5,16 @@ description: "Use when the user needs to run GitNexus CLI commands like analyze/
# GitNexus CLI Commands
All commands work via `npx` — no global install required.
Commands below use `node .gitnexus/run.cjs <command>` — the project-local runner `gitnexus analyze` drops next to the index. It auto-selects an available runner at call time (global `gitnexus`, else `pnpm dlx`, else `npx`), so no package-manager assumption and no global install is required.
> **Not analyzed yet, or `node .gitnexus/run.cjs` reports `Cannot find module`** (the gitignored runner is absent — e.g. a fresh clone or `git clean`)? (Re)generate it with `npx gitnexus analyze` from the project root. On **npm 11.x**, if `npx` crashes during install (`node.target is null`), install once with `npm i -g gitnexus` (then `gitnexus analyze`) or use `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939).
## Commands
### analyze — Build or refresh the index
```bash
npx gitnexus analyze
node .gitnexus/run.cjs analyze
```
Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files.
@ -28,7 +30,7 @@ Run from the project root. This parses all source files, builds the knowledge gr
### status — Check index freshness
```bash
npx gitnexus status
node .gitnexus/run.cjs status
```
Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed.
@ -36,7 +38,7 @@ Shows whether the current repo has a GitNexus index, when it was last updated, a
### clean — Delete the index
```bash
npx gitnexus clean
node .gitnexus/run.cjs clean
```
Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.
@ -49,7 +51,7 @@ Deletes the `.gitnexus/` directory and unregisters the repo from the global regi
### wiki — Generate documentation from the graph
```bash
npx gitnexus wiki
node .gitnexus/run.cjs wiki
```
Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
@ -66,7 +68,7 @@ Generates repository documentation from the knowledge graph using an LLM. Requir
### list — Show all indexed repos
```bash
npx gitnexus list
node .gitnexus/run.cjs list
```
Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.

View file

@ -22,7 +22,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
```
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
## Checklist

View file

@ -23,7 +23,7 @@ description: "Use when the user asks how code works, wants to understand archite
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
```
> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal.
> If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
## Checklist

View file

@ -15,7 +15,7 @@ For any task involving code understanding, debugging, impact analysis, or refact
2. **Match your task to a skill below** and **read that skill file**
3. **Follow the skill's workflow and checklist**
> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
> If step 1 warns the index is stale, run `node .gitnexus/run.cjs analyze` in the terminal first.
## Skills

View file

@ -23,7 +23,7 @@ description: "Use when the user wants to know what will break if they change som
4. Assess risk and report to user
```
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
## Checklist

View file

@ -26,7 +26,7 @@ description: "Use when the user wants to review a pull request, understand what
6. Summarize findings with risk assessment
```
> If "Index is stale" → run `npx gitnexus analyze` in terminal before reviewing.
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal before reviewing.
## Checklist

View file

@ -22,7 +22,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
4. Plan update order: interfaces → implementations → callers → tests
```
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
## Checklists

View file

@ -77,7 +77,7 @@ commits, or posts.
This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
## Always Do

View file

@ -58,7 +58,7 @@ See the `<!-- gitnexus:start --> … <!-- gitnexus:end -->` block in **[AGENTS.m
This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
## Always Do

View file

@ -104,6 +104,14 @@ npx gitnexus analyze
That's it. This indexes the codebase, installs agent skills, registers Claude Code hooks, and creates `AGENTS.md` / `CLAUDE.md` context files — all in one command.
> **On npm 11.x?** `npx` can crash during install with `Cannot destructure property 'package' of 'node.target'` (an npm/arborist bug, before GitNexus runs). Use pnpm instead — it builds the native deps explicitly:
>
> ```bash
> pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze
> ```
>
> Or install globally (`npm install -g gitnexus@latest`) and run `gitnexus analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939).
To configure MCP for your editor, run `npx gitnexus setup` once — or set it up manually below.
> **Faster install (no C++ toolchain needed):** set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before `npm install -g gitnexus` to skip vendored grammar materialize/build (`tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`). Dart/Proto/Swift files won't be parsed, but install completes in seconds without `python3`/`make`/`g++`. Strict `=1` only — any other value falls through to the rebuild.

View file

@ -16,6 +16,7 @@ const path = require('path');
const { spawnSync } = require('child_process');
const { acquireHookSlot } = require('./hook-lock.js');
const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs');
const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs');
/**
* Read JSON input from stdin synchronously.
@ -345,7 +346,7 @@ function handlePostToolUse(input) {
// If HEAD matches last indexed commit, no reindex needed
if (currentHead && currentHead === lastCommit) return;
const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`;
const analyzeCmd = formatAnalyzeCommand({ embeddings: hadEmbeddings });
sendHookResponse(
'PostToolUse',
`GitNexus index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` +

View file

@ -0,0 +1,295 @@
/**
* Single source of truth for how docs, hooks, and warnings invoke gitnexus.
*
* Automatically selects a working invocation path:
* 1. Global `gitnexus` on PATH (best — no install step)
* 2. npm 11+ with pnpm on PATH → `pnpm --allow-build=… dlx` (avoids the npx
* arborist crash *and* pnpm 10+ ignored-build-script failures, #1939)
* 3. npm < 11 with npm on PATH → `npx` (works; simpler than pnpm dlx)
* 4. pnpm-only → `pnpm --allow-build=… dlx`
* 5. Last resort → `npx` (warned on npm 11+ from analyze.ts)
*
* The `--allow-build` flags MUST precede the `dlx` token. pnpm < 10.14 keeps
* `dlx` in its argv escape list, so flags placed *after* `dlx` are parsed as
* package specs (ERR_PNPM_SPEC_NOT_SUPPORTED). The pre-`dlx` position parses
* into dlx's allow-build option and has been honored since pnpm 10.2.0 (#1939).
*
* This stays self-contained CJS because the Claude/Antigravity hooks run as
* standalone files copied into the user's hook dir, where no package import is
* available. The CLI reuses this module from src/cli/resolve-invocation.ts via
* createRequire rather than re-implementing it. Two committed copies must stay
* byte-identical (enforced by resolve-invocation.test.ts) — edit both together:
* gitnexus/hooks/claude/ (the canonical copy the CLI and `gitnexus setup` read)
* and gitnexus-claude-plugin/hooks/. A THIRD copy is written at runtime to
* `<repo>/.gitnexus/run.cjs` by `gitnexus analyze` (ai-context.ts) so docs can
* reference it directly via the `require.main === module` exec tail below; that
* copy is gitignored and refreshed on every analyze, so it cannot drift for long.
*/
const { execFileSync } = require('child_process');
const NPX_REF = 'gitnexus@latest';
// Native packages whose postinstall must run under pnpm 10+ (blocked by default).
const PNPM_ALLOW_BUILD_BASE = ['@ladybugdb/core', 'gitnexus', 'tree-sitter'];
const PNPM_ALLOW_BUILD_EMBEDDINGS = ['onnxruntime-node'];
// Probe timeout, kept under Claude Code's 10s hook budget. In a linked worktree
// the stale-index hook first runs `git rev-parse --git-common-dir` (~2s) and
// `git rev-parse HEAD` (~3s); the pnpm path then adds up to four 1s probes
// (which gitnexus, npm --version, which pnpm, pnpm --version), so the worst case
// is ~9s — within budget but tight. A healthy `which`/`where`/`--version`
// returns in well under a second, so the realistic cost is far lower.
const PROBE_TIMEOUT_MS = 1000;
/**
* Pick the best match from `where`/`which` output. A global `gitnexus` may be a
* `.cmd`/`.bat` (npm), a `.exe`, or an extensionless shim (Volta, scoop), so on
* Windows we prefer a recognized executable extension but accept any hit — the
* emitted hint is `gitnexus analyze` regardless of which shim resolves it. Pure
* and exported so the shim-matching can be unit-tested without spawning.
*/
function pickPathMatch(output, { isWin, gitnexusWrapper } = {}) {
const lines = output
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
if (isWin && gitnexusWrapper) {
return lines.find((l) => /\.(cmd|bat|exe)$/i.test(l)) || lines[0] || null;
}
return lines[0] || null;
}
/** Absolute path to `command` on PATH, or null. `gitnexusWrapper` enables the Windows shim match. */
function resolveOnPath(command, gitnexusWrapper = false) {
const isWin = process.platform === 'win32';
try {
const output = execFileSync(isWin ? 'where' : 'which', [command], {
encoding: 'utf-8',
timeout: PROBE_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
});
return pickPathMatch(output, { isWin, gitnexusWrapper });
} catch {
return null;
}
}
// One spawn of `<command> --version` → { major, minor } (each null when
// unreadable). Version injection happens at the resolver seam (getNpmMajorVersion
// / formatPnpmAllowBuildArgs), so this stays a pure real-process probe.
function probeVersion(command) {
try {
const output = execFileSync(command, ['--version'], {
encoding: 'utf-8',
timeout: PROBE_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
// On Windows, npm/pnpm resolve to `.cmd` shims; execFileSync does no
// PATHEXT resolution and Node refuses to spawn `.cmd`/`.bat` without a
// shell (CVE-2024-27980), so a bare `<command> --version` ENOENTs and the
// probe would wrongly report a present tool as absent. A shell lets the OS
// resolve the shim. POSIX needs no shell (direct PATH lookup works).
shell: process.platform === 'win32',
});
// Find the first line that starts with a version token (`MAJOR.MINOR`,
// optional `v` prefix) rather than splitting the whole output — pnpm/npm
// under Corepack or with an update notice can print a banner line on stdout
// before the version (stderr is already dropped via the stdio config).
const versionLine = output
.split('\n')
.map((l) => l.trim())
.find((l) => /^v?\d+\.\d+/.test(l));
const match = versionLine ? versionLine.match(/^v?(\d+)\.(\d+)/) : null;
return {
major: match ? Number(match[1]) : null,
minor: match ? Number(match[2]) : null,
};
} catch {
return { major: null, minor: null };
}
}
// `deps` is the single injection seam: an explicitly provided key — including a
// `null` value, detected via `in` — is honored as-is so tests can simulate an
// absent tool without spawning; an absent key falls through to the real probe.
function getNpmMajorVersion(deps = {}) {
return 'npmMajor' in deps ? deps.npmMajor : probeVersion('npm').major;
}
/**
* `--allow-build` flags for the pre-`dlx` position. Emitted for pnpm >= 10.2
* (where the flag exists, and pnpm 10+ blocks build scripts by default). Omitted
* below 10.2: pnpm < 10 runs build scripts anyway, and pnpm 10.0/10.1 lack the
* flag (it would be rejected as an unknown option). `alwaysAllowBuild` forces the
* flags for committed documentation, which cannot probe the reader's pnpm.
*/
function formatPnpmAllowBuildArgs(options = {}, deps = {}) {
if (!options.alwaysAllowBuild) {
const { major, minor } =
'pnpmMajor' in deps
? { major: deps.pnpmMajor, minor: 'pnpmMinor' in deps ? deps.pnpmMinor : null }
: probeVersion('pnpm');
const lacksAllowBuild =
major !== null && (major < 10 || (major === 10 && minor !== null && minor < 2));
if (lacksAllowBuild) return [];
}
const pkgs = [...PNPM_ALLOW_BUILD_BASE];
if (options.embeddings) pkgs.push(...PNPM_ALLOW_BUILD_EMBEDDINGS);
return pkgs.map((p) => `--allow-build=${p}`);
}
/** Fixed install-free command for committed AGENTS.md / SKILL.md (pnpm >= 10.2). */
function formatDocumentationDlxCommand(gitnexusArgs, options = {}) {
const flags = formatPnpmAllowBuildArgs({ ...options, alwaysAllowBuild: true }).join(' ');
const prefix = flags ? `${flags} ` : '';
return `pnpm ${prefix}dlx ${NPX_REF} ${gitnexusArgs}`;
}
/**
* Resolve `gitnexus` | `pnpm` | `npx`. `GITNEXUS_INVOCATION` forces a mode
* (test/escape hatch). `probe` is injectable so the preference order can be
* unit-tested without spawning; it defaults to the real PATH probe. `deps` can
* inject `{ npmMajor, pnpmMajor }` for tests.
*/
function resolveInvocationMode(probe = resolveOnPath, deps = {}) {
const forced = process.env.GITNEXUS_INVOCATION?.trim().toLowerCase();
if (forced === 'gitnexus' || forced === 'pnpm' || forced === 'npx') {
return forced;
}
if (probe('gitnexus', true)) return 'gitnexus';
const npmMajor = getNpmMajorVersion(deps);
// pnpm presence: prefer an explicit `pnpmPresent` flag (set by
// formatAnalyzeCommand, which falls back to a PATH probe when the version is
// unreadable) so a present-but-unparseable pnpm — slow probe, Corepack
// banner — still selects pnpm instead of the npx crash path. Otherwise an
// injected version (a successful `pnpm --version` proves presence)
// short-circuits the `which pnpm` probe; failing both, fall back to PATH.
const hasPnpm =
'pnpmPresent' in deps
? deps.pnpmPresent
: 'pnpmMajor' in deps
? deps.pnpmMajor !== null
: Boolean(probe('pnpm'));
// npm 11+ npx install crash (#1939) — prefer pnpm dlx when available.
if (hasPnpm && npmMajor !== null && npmMajor >= 11) return 'pnpm';
// npm 10 and earlier: npx works; prefer it over pnpm dlx when npm is present.
if (npmMajor !== null && npmMajor < 11) return 'npx';
// npm absent or unreadable — use pnpm if present (with allow-build flags).
if (hasPnpm) return 'pnpm';
return 'npx';
}
function formatPnpmDlxCommand(gitnexusArgs, options = {}, deps = {}) {
const flags = formatPnpmAllowBuildArgs(options, deps).join(' ');
const prefix = flags ? `${flags} ` : '';
return `pnpm ${prefix}dlx ${NPX_REF} ${gitnexusArgs}`;
}
function formatAnalyzeCommand(options = {}, deps = {}) {
const suffix = options.embeddings ? ' --embeddings' : '';
// Keep the stale-index hook budget tight by querying each tool at most once.
// A memoized PATH probe is shared with resolveInvocationMode (so `gitnexus`
// isn't probed twice), and pnpm's version is captured by a single
// `pnpm --version` that proves both presence (for mode resolution) and
// version (for the allow-build gate) — replacing the former `which pnpm` +
// `pnpm --version` double spawn. Injected deps (tests) and forced/global
// modes skip the pnpm probe.
const cache = new Map();
const probe = (command, gitnexusWrapper) => {
const key = `${command}:${gitnexusWrapper ? 1 : 0}`;
if (!cache.has(key)) cache.set(key, resolveOnPath(command, gitnexusWrapper));
return cache.get(key);
};
let resolved = deps;
if (!('pnpmMajor' in deps)) {
const forced = process.env.GITNEXUS_INVOCATION?.trim().toLowerCase();
// pnpm is only consulted when no non-pnpm mode is already certain: forced
// gitnexus/npx never use pnpm, and a present global gitnexus wins outright.
const mightUsePnpm = forced === 'pnpm' || (forced !== 'gitnexus' && forced !== 'npx');
if (mightUsePnpm && (forced === 'pnpm' || !probe('gitnexus', true))) {
const { major, minor } = probeVersion('pnpm');
// Carry presence separately from version: when the version probe fails
// (timeout, Corepack banner) but pnpm is on PATH, still treat it as
// present so mode resolution picks pnpm over the npx crash path. The
// PATH probe is memoized and only runs when the version is unreadable.
const pnpmPresent = major !== null || Boolean(probe('pnpm'));
resolved = { ...deps, pnpmMajor: major, pnpmMinor: minor, pnpmPresent };
}
}
const mode = resolveInvocationMode(probe, resolved);
if (mode === 'gitnexus') return `gitnexus analyze${suffix}`;
if (mode === 'pnpm') return `${formatPnpmDlxCommand(`analyze${suffix}`, options, resolved)}`;
return `npx ${NPX_REF} analyze${suffix}`;
}
/**
* Resolve `mode` into a concrete { program, args } pair for a set of gitnexus
* subcommand arguments. Shared by the direct-exec entrypoint below; pure (no
* spawn) so it is unit-testable. `--embeddings` widens the pnpm allow-build set.
*/
function buildRunnerArgv(mode, gitnexusArgs, deps = {}) {
// Match both the space form (`--embeddings`) and the equals form
// (`--embeddings=5000`) Commander accepts, so the pnpm allow-build set still
// widens to onnxruntime-node when a user hand-types the equals form.
const embeddings = gitnexusArgs.some(
(a) => a === '--embeddings' || a.startsWith('--embeddings='),
);
if (mode === 'gitnexus') return { program: 'gitnexus', args: [...gitnexusArgs] };
if (mode === 'pnpm') {
return {
program: 'pnpm',
args: [...formatPnpmAllowBuildArgs({ embeddings }, deps), 'dlx', NPX_REF, ...gitnexusArgs],
};
}
return { program: 'npx', args: [NPX_REF, ...gitnexusArgs] };
}
module.exports = {
formatAnalyzeCommand,
formatDocumentationDlxCommand,
formatPnpmAllowBuildArgs,
formatPnpmDlxCommand,
resolveInvocationMode,
buildRunnerArgv,
pickPathMatch,
getNpmMajorVersion,
NPX_REF,
PNPM_ALLOW_BUILD_BASE,
};
// Direct-exec entrypoint (#1945): `node run.cjs <gitnexus args…>` resolves the
// best available runner (global `gitnexus` → `pnpm dlx` → `npx`) at call time and
// runs it, inheriting stdio and propagating the child's exit code. This lets the
// committed skills and generated AGENTS.md/CLAUDE.md reference ONE stable,
// CLI-neutral command without baking in a package-manager assumption. `gitnexus
// analyze` drops a copy of this file at `.gitnexus/run.cjs`. Skipped on require()
// (the CLI and tests reuse the exports above), so it runs only when invoked as a
// script.
if (require.main === module) {
const gitnexusArgs = process.argv.slice(2);
const { program, args } = buildRunnerArgv(resolveInvocationMode(), gitnexusArgs);
try {
execFileSync(program, args, {
stdio: 'inherit',
windowsHide: true,
// On Windows, `npx`/`pnpm`/`gitnexus` resolve to `.cmd`/`.ps1`/`.exe`
// shims (npm, Volta, Corepack, scoop). execFileSync does not do PATHEXT
// resolution and Node refuses to spawn `.cmd`/`.bat` without a shell
// (CVE-2024-27980), so a bare program name ENOENTs. A shell lets the OS
// resolve the shim; POSIX needs no shell (direct PATH lookup works).
shell: process.platform === 'win32',
});
} catch (err) {
// Make spawn failures (resolved program absent from PATH) self-explanatory
// instead of a silent exit 1, then propagate the runner's own exit code.
if (typeof err.status !== 'number') {
process.stderr.write(`gitnexus runner: could not launch \`${program}\` — ${err.message}\n`);
}
process.exit(typeof err.status === 'number' ? err.status : 1);
}
}

View file

@ -5,14 +5,16 @@ description: "Use when the user needs to run GitNexus CLI commands like analyze/
# GitNexus CLI Commands
All commands work via `npx` — no global install required.
Commands below use `node .gitnexus/run.cjs <command>` — the project-local runner `gitnexus analyze` drops next to the index. It auto-selects an available runner at call time (global `gitnexus`, else `pnpm dlx`, else `npx`), so no package-manager assumption and no global install is required.
> **Not analyzed yet, or `node .gitnexus/run.cjs` reports `Cannot find module`** (the gitignored runner is absent — e.g. a fresh clone or `git clean`)? (Re)generate it with `npx gitnexus analyze` from the project root. On **npm 11.x**, if `npx` crashes during install (`node.target is null`), install once with `npm i -g gitnexus` (then `gitnexus analyze`) or use `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939).
## Commands
### analyze — Build or refresh the index
```bash
npx gitnexus analyze
node .gitnexus/run.cjs analyze
```
Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files.
@ -28,7 +30,7 @@ Run from the project root. This parses all source files, builds the knowledge gr
### status — Check index freshness
```bash
npx gitnexus status
node .gitnexus/run.cjs status
```
Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed.
@ -36,7 +38,7 @@ Shows whether the current repo has a GitNexus index, when it was last updated, a
### clean — Delete the index
```bash
npx gitnexus clean
node .gitnexus/run.cjs clean
```
Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.
@ -49,7 +51,7 @@ Deletes the `.gitnexus/` directory and unregisters the repo from the global regi
### wiki — Generate documentation from the graph
```bash
npx gitnexus wiki
node .gitnexus/run.cjs wiki
```
Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
@ -68,7 +70,7 @@ Generates repository documentation from the knowledge graph using an LLM. Requir
### list — Show all indexed repos
```bash
npx gitnexus list
node .gitnexus/run.cjs list
```
Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.

View file

@ -22,7 +22,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
```
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
## Checklist

View file

@ -23,7 +23,7 @@ description: "Use when the user asks how code works, wants to understand archite
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
```
> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal.
> If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
## Checklist

View file

@ -15,7 +15,7 @@ For any task involving code understanding, debugging, impact analysis, or refact
2. **Match your task to a skill below** and **read that skill file**
3. **Follow the skill's workflow and checklist**
> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
> If step 1 warns the index is stale, run `node .gitnexus/run.cjs analyze` in the terminal first.
## Skills

View file

@ -23,7 +23,7 @@ description: "Use when the user wants to know what will break if they change som
4. Assess risk and report to user
```
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
## Checklist

View file

@ -26,7 +26,7 @@ description: "Use when the user wants to review a pull request, understand what
6. Summarize findings with risk assessment
```
> If "Index is stale" → run `npx gitnexus analyze` in terminal before reviewing.
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal before reviewing.
## Checklist

View file

@ -22,7 +22,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
4. Plan update order: interfaces → implementations → callers → tests
```
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
## Checklists

View file

@ -40,7 +40,7 @@ If you already have a `.cursor/hooks.json`, merge the `hooks.postToolUse` array
### Verify
1. Index the project: `npx gitnexus analyze`
1. Index the project: `npx gitnexus analyze` (on npm 11.x, `npx` can crash during install — use `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze` instead; see [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939))
2. Reload the Cursor window so it picks up the new hook config.
3. Ask the agent something that triggers `Read` / `Grep` / `Shell rg`. You should see a `[GitNexus]` block appended to the tool result.
4. Diagnose silent no-ops by setting `GITNEXUS_DEBUG=1` in your shell environment — the hook will write Cursor's raw event payload to stderr so you can verify field names.

View file

@ -21,7 +21,7 @@ description: Trace bugs through call chains using knowledge graph
4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
```
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
## Checklist

View file

@ -22,7 +22,7 @@ description: Navigate unfamiliar code using GitNexus knowledge graph
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
```
> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal.
> If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
## Checklist

View file

@ -22,7 +22,7 @@ description: Analyze blast radius before making code changes
4. Assess risk and report to user
```
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
## Checklist

View file

@ -26,7 +26,7 @@ description: "Use when the user wants to review a pull request, understand what
6. Summarize findings with risk assessment
```
> If "Index is stale" → run `npx gitnexus analyze` in terminal before reviewing.
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal before reviewing.
## Checklist

View file

@ -21,7 +21,7 @@ description: Plan safe refactors using blast radius and dependency mapping
4. Plan update order: interfaces → implementations → callers → tests
```
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
## Checklists

View file

@ -24,6 +24,14 @@ npx gitnexus analyze
That's it. This indexes the codebase, installs agent skills, registers Claude Code hooks, and creates `AGENTS.md` / `CLAUDE.md` context files — all in one command.
> **On npm 11.x?** `npx` can crash during install (`Cannot destructure property 'package' of 'node.target'`). Use the pnpm form instead:
>
> ```bash
> pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze
> ```
>
> See [Troubleshooting → `npx gitnexus` crashes with `node.target is null` (npm 11)](#cannot-destructure-property-package-of-nodetarget-as-it-is-null) for the full matrix (global install, npm downgrade).
To configure MCP for your editor, run `npx gitnexus setup` once — or set it up manually below.
`gitnexus setup` auto-detects your editors and writes the correct global MCP config. You only need to run it once.
@ -273,22 +281,27 @@ for the full list; stable `latest` is unaffected.
### `Cannot destructure property 'package' of 'node.target' as it is null`
This crash was caused by a dependency URL format that is incompatible with
certain npm/arborist versions ([npm/cli#8126](https://github.com/npm/cli/issues/8126)).
It is fixed in **gitnexus v1.6.2+**. Upgrade to the latest version:
This error comes from **npm 11.x's arborist** while installing gitnexus (often via `npx`), before gitnexus code runs. It is triggered by platform-filtered `optionalDependencies` in native packages such as `onnxruntime-node` / `@huggingface/transformers` (used when indexing with `--embeddings`). GitNexus cannot catch it at runtime — use one of these workarounds:
```bash
npx gitnexus@latest analyze # always uses the newest release
# — or —
npm install -g gitnexus@latest # upgrade a global install
pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze # auto-selected when pnpm + npm 11+
npm install -g gitnexus@latest # global install avoids per-run npx reify
gitnexus analyze # if already installed globally
```
If you still hit npm install issues after upgrading, these generic workarounds
may help:
On **pnpm 10+**, lifecycle scripts are blocked unless explicitly allowed — the resolver adds `--allow-build` for `@ladybugdb/core`, `gitnexus`, and `tree-sitter` automatically when it picks `pnpm dlx`.
If you must stay on npm 11.x without pnpm, downgrade npm toolchain-wide (last resort):
```bash
npm install -g npm@latest # update npm itself
npm cache clean --force # clear a possibly corrupt cache
npm install -g npm@10.9.0
```
See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939) and the original [#819](https://github.com/abhigyanpatwari/GitNexus/issues/819) thread. An older variant of this crash (tree-sitter-dart tarball URL) was fixed in gitnexus v1.6.2+ ([#820](https://github.com/abhigyanpatwari/GitNexus/pull/820)); if you still see install failures after upgrading, clear cache:
```bash
npm cache clean --force
npx gitnexus@latest analyze
```
### `ERR_DLOPEN_FAILED` / `lbugjs.node` missing (pnpm dlx, pnpx)

View file

@ -25,6 +25,7 @@ const path = require('path');
const { spawnSync } = require('child_process');
const { acquireHookSlot } = require('./hook-lock.cjs');
const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs');
const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs');
function readInput() {
try {
@ -315,7 +316,7 @@ function buildStaleIndexHint(gitNexusDir, cwd) {
if (currentHead === lastCommit) return '';
const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`;
const analyzeCmd = formatAnalyzeCommand({ embeddings: hadEmbeddings });
return (
`[GitNexus] index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` +
`Run \`${analyzeCmd}\` to refresh the knowledge graph.`

View file

@ -16,6 +16,7 @@ const path = require('path');
const { spawnSync } = require('child_process');
const { acquireHookSlot } = require('./hook-lock.cjs');
const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs');
const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs');
/**
* Read JSON input from stdin synchronously.
@ -340,7 +341,7 @@ function handlePostToolUse(input) {
// If HEAD matches last indexed commit, no reindex needed
if (currentHead && currentHead === lastCommit) return;
const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`;
const analyzeCmd = formatAnalyzeCommand({ embeddings: hadEmbeddings });
sendHookResponse(
'PostToolUse',
`GitNexus index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` +

View file

@ -0,0 +1,295 @@
/**
* Single source of truth for how docs, hooks, and warnings invoke gitnexus.
*
* Automatically selects a working invocation path:
* 1. Global `gitnexus` on PATH (best — no install step)
* 2. npm 11+ with pnpm on PATH → `pnpm --allow-build=… dlx` (avoids the npx
* arborist crash *and* pnpm 10+ ignored-build-script failures, #1939)
* 3. npm < 11 with npm on PATH → `npx` (works; simpler than pnpm dlx)
* 4. pnpm-only → `pnpm --allow-build=… dlx`
* 5. Last resort → `npx` (warned on npm 11+ from analyze.ts)
*
* The `--allow-build` flags MUST precede the `dlx` token. pnpm < 10.14 keeps
* `dlx` in its argv escape list, so flags placed *after* `dlx` are parsed as
* package specs (ERR_PNPM_SPEC_NOT_SUPPORTED). The pre-`dlx` position parses
* into dlx's allow-build option and has been honored since pnpm 10.2.0 (#1939).
*
* This stays self-contained CJS because the Claude/Antigravity hooks run as
* standalone files copied into the user's hook dir, where no package import is
* available. The CLI reuses this module from src/cli/resolve-invocation.ts via
* createRequire rather than re-implementing it. Two committed copies must stay
* byte-identical (enforced by resolve-invocation.test.ts) — edit both together:
* gitnexus/hooks/claude/ (the canonical copy the CLI and `gitnexus setup` read)
* and gitnexus-claude-plugin/hooks/. A THIRD copy is written at runtime to
* `<repo>/.gitnexus/run.cjs` by `gitnexus analyze` (ai-context.ts) so docs can
* reference it directly via the `require.main === module` exec tail below; that
* copy is gitignored and refreshed on every analyze, so it cannot drift for long.
*/
const { execFileSync } = require('child_process');
const NPX_REF = 'gitnexus@latest';
// Native packages whose postinstall must run under pnpm 10+ (blocked by default).
const PNPM_ALLOW_BUILD_BASE = ['@ladybugdb/core', 'gitnexus', 'tree-sitter'];
const PNPM_ALLOW_BUILD_EMBEDDINGS = ['onnxruntime-node'];
// Probe timeout, kept under Claude Code's 10s hook budget. In a linked worktree
// the stale-index hook first runs `git rev-parse --git-common-dir` (~2s) and
// `git rev-parse HEAD` (~3s); the pnpm path then adds up to four 1s probes
// (which gitnexus, npm --version, which pnpm, pnpm --version), so the worst case
// is ~9s — within budget but tight. A healthy `which`/`where`/`--version`
// returns in well under a second, so the realistic cost is far lower.
const PROBE_TIMEOUT_MS = 1000;
/**
* Pick the best match from `where`/`which` output. A global `gitnexus` may be a
* `.cmd`/`.bat` (npm), a `.exe`, or an extensionless shim (Volta, scoop), so on
* Windows we prefer a recognized executable extension but accept any hit — the
* emitted hint is `gitnexus analyze` regardless of which shim resolves it. Pure
* and exported so the shim-matching can be unit-tested without spawning.
*/
function pickPathMatch(output, { isWin, gitnexusWrapper } = {}) {
const lines = output
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
if (isWin && gitnexusWrapper) {
return lines.find((l) => /\.(cmd|bat|exe)$/i.test(l)) || lines[0] || null;
}
return lines[0] || null;
}
/** Absolute path to `command` on PATH, or null. `gitnexusWrapper` enables the Windows shim match. */
function resolveOnPath(command, gitnexusWrapper = false) {
const isWin = process.platform === 'win32';
try {
const output = execFileSync(isWin ? 'where' : 'which', [command], {
encoding: 'utf-8',
timeout: PROBE_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
});
return pickPathMatch(output, { isWin, gitnexusWrapper });
} catch {
return null;
}
}
// One spawn of `<command> --version` → { major, minor } (each null when
// unreadable). Version injection happens at the resolver seam (getNpmMajorVersion
// / formatPnpmAllowBuildArgs), so this stays a pure real-process probe.
function probeVersion(command) {
try {
const output = execFileSync(command, ['--version'], {
encoding: 'utf-8',
timeout: PROBE_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
// On Windows, npm/pnpm resolve to `.cmd` shims; execFileSync does no
// PATHEXT resolution and Node refuses to spawn `.cmd`/`.bat` without a
// shell (CVE-2024-27980), so a bare `<command> --version` ENOENTs and the
// probe would wrongly report a present tool as absent. A shell lets the OS
// resolve the shim. POSIX needs no shell (direct PATH lookup works).
shell: process.platform === 'win32',
});
// Find the first line that starts with a version token (`MAJOR.MINOR`,
// optional `v` prefix) rather than splitting the whole output — pnpm/npm
// under Corepack or with an update notice can print a banner line on stdout
// before the version (stderr is already dropped via the stdio config).
const versionLine = output
.split('\n')
.map((l) => l.trim())
.find((l) => /^v?\d+\.\d+/.test(l));
const match = versionLine ? versionLine.match(/^v?(\d+)\.(\d+)/) : null;
return {
major: match ? Number(match[1]) : null,
minor: match ? Number(match[2]) : null,
};
} catch {
return { major: null, minor: null };
}
}
// `deps` is the single injection seam: an explicitly provided key — including a
// `null` value, detected via `in` — is honored as-is so tests can simulate an
// absent tool without spawning; an absent key falls through to the real probe.
function getNpmMajorVersion(deps = {}) {
return 'npmMajor' in deps ? deps.npmMajor : probeVersion('npm').major;
}
/**
* `--allow-build` flags for the pre-`dlx` position. Emitted for pnpm >= 10.2
* (where the flag exists, and pnpm 10+ blocks build scripts by default). Omitted
* below 10.2: pnpm < 10 runs build scripts anyway, and pnpm 10.0/10.1 lack the
* flag (it would be rejected as an unknown option). `alwaysAllowBuild` forces the
* flags for committed documentation, which cannot probe the reader's pnpm.
*/
function formatPnpmAllowBuildArgs(options = {}, deps = {}) {
if (!options.alwaysAllowBuild) {
const { major, minor } =
'pnpmMajor' in deps
? { major: deps.pnpmMajor, minor: 'pnpmMinor' in deps ? deps.pnpmMinor : null }
: probeVersion('pnpm');
const lacksAllowBuild =
major !== null && (major < 10 || (major === 10 && minor !== null && minor < 2));
if (lacksAllowBuild) return [];
}
const pkgs = [...PNPM_ALLOW_BUILD_BASE];
if (options.embeddings) pkgs.push(...PNPM_ALLOW_BUILD_EMBEDDINGS);
return pkgs.map((p) => `--allow-build=${p}`);
}
/** Fixed install-free command for committed AGENTS.md / SKILL.md (pnpm >= 10.2). */
function formatDocumentationDlxCommand(gitnexusArgs, options = {}) {
const flags = formatPnpmAllowBuildArgs({ ...options, alwaysAllowBuild: true }).join(' ');
const prefix = flags ? `${flags} ` : '';
return `pnpm ${prefix}dlx ${NPX_REF} ${gitnexusArgs}`;
}
/**
* Resolve `gitnexus` | `pnpm` | `npx`. `GITNEXUS_INVOCATION` forces a mode
* (test/escape hatch). `probe` is injectable so the preference order can be
* unit-tested without spawning; it defaults to the real PATH probe. `deps` can
* inject `{ npmMajor, pnpmMajor }` for tests.
*/
function resolveInvocationMode(probe = resolveOnPath, deps = {}) {
const forced = process.env.GITNEXUS_INVOCATION?.trim().toLowerCase();
if (forced === 'gitnexus' || forced === 'pnpm' || forced === 'npx') {
return forced;
}
if (probe('gitnexus', true)) return 'gitnexus';
const npmMajor = getNpmMajorVersion(deps);
// pnpm presence: prefer an explicit `pnpmPresent` flag (set by
// formatAnalyzeCommand, which falls back to a PATH probe when the version is
// unreadable) so a present-but-unparseable pnpm — slow probe, Corepack
// banner — still selects pnpm instead of the npx crash path. Otherwise an
// injected version (a successful `pnpm --version` proves presence)
// short-circuits the `which pnpm` probe; failing both, fall back to PATH.
const hasPnpm =
'pnpmPresent' in deps
? deps.pnpmPresent
: 'pnpmMajor' in deps
? deps.pnpmMajor !== null
: Boolean(probe('pnpm'));
// npm 11+ npx install crash (#1939) — prefer pnpm dlx when available.
if (hasPnpm && npmMajor !== null && npmMajor >= 11) return 'pnpm';
// npm 10 and earlier: npx works; prefer it over pnpm dlx when npm is present.
if (npmMajor !== null && npmMajor < 11) return 'npx';
// npm absent or unreadable — use pnpm if present (with allow-build flags).
if (hasPnpm) return 'pnpm';
return 'npx';
}
function formatPnpmDlxCommand(gitnexusArgs, options = {}, deps = {}) {
const flags = formatPnpmAllowBuildArgs(options, deps).join(' ');
const prefix = flags ? `${flags} ` : '';
return `pnpm ${prefix}dlx ${NPX_REF} ${gitnexusArgs}`;
}
function formatAnalyzeCommand(options = {}, deps = {}) {
const suffix = options.embeddings ? ' --embeddings' : '';
// Keep the stale-index hook budget tight by querying each tool at most once.
// A memoized PATH probe is shared with resolveInvocationMode (so `gitnexus`
// isn't probed twice), and pnpm's version is captured by a single
// `pnpm --version` that proves both presence (for mode resolution) and
// version (for the allow-build gate) — replacing the former `which pnpm` +
// `pnpm --version` double spawn. Injected deps (tests) and forced/global
// modes skip the pnpm probe.
const cache = new Map();
const probe = (command, gitnexusWrapper) => {
const key = `${command}:${gitnexusWrapper ? 1 : 0}`;
if (!cache.has(key)) cache.set(key, resolveOnPath(command, gitnexusWrapper));
return cache.get(key);
};
let resolved = deps;
if (!('pnpmMajor' in deps)) {
const forced = process.env.GITNEXUS_INVOCATION?.trim().toLowerCase();
// pnpm is only consulted when no non-pnpm mode is already certain: forced
// gitnexus/npx never use pnpm, and a present global gitnexus wins outright.
const mightUsePnpm = forced === 'pnpm' || (forced !== 'gitnexus' && forced !== 'npx');
if (mightUsePnpm && (forced === 'pnpm' || !probe('gitnexus', true))) {
const { major, minor } = probeVersion('pnpm');
// Carry presence separately from version: when the version probe fails
// (timeout, Corepack banner) but pnpm is on PATH, still treat it as
// present so mode resolution picks pnpm over the npx crash path. The
// PATH probe is memoized and only runs when the version is unreadable.
const pnpmPresent = major !== null || Boolean(probe('pnpm'));
resolved = { ...deps, pnpmMajor: major, pnpmMinor: minor, pnpmPresent };
}
}
const mode = resolveInvocationMode(probe, resolved);
if (mode === 'gitnexus') return `gitnexus analyze${suffix}`;
if (mode === 'pnpm') return `${formatPnpmDlxCommand(`analyze${suffix}`, options, resolved)}`;
return `npx ${NPX_REF} analyze${suffix}`;
}
/**
* Resolve `mode` into a concrete { program, args } pair for a set of gitnexus
* subcommand arguments. Shared by the direct-exec entrypoint below; pure (no
* spawn) so it is unit-testable. `--embeddings` widens the pnpm allow-build set.
*/
function buildRunnerArgv(mode, gitnexusArgs, deps = {}) {
// Match both the space form (`--embeddings`) and the equals form
// (`--embeddings=5000`) Commander accepts, so the pnpm allow-build set still
// widens to onnxruntime-node when a user hand-types the equals form.
const embeddings = gitnexusArgs.some(
(a) => a === '--embeddings' || a.startsWith('--embeddings='),
);
if (mode === 'gitnexus') return { program: 'gitnexus', args: [...gitnexusArgs] };
if (mode === 'pnpm') {
return {
program: 'pnpm',
args: [...formatPnpmAllowBuildArgs({ embeddings }, deps), 'dlx', NPX_REF, ...gitnexusArgs],
};
}
return { program: 'npx', args: [NPX_REF, ...gitnexusArgs] };
}
module.exports = {
formatAnalyzeCommand,
formatDocumentationDlxCommand,
formatPnpmAllowBuildArgs,
formatPnpmDlxCommand,
resolveInvocationMode,
buildRunnerArgv,
pickPathMatch,
getNpmMajorVersion,
NPX_REF,
PNPM_ALLOW_BUILD_BASE,
};
// Direct-exec entrypoint (#1945): `node run.cjs <gitnexus args…>` resolves the
// best available runner (global `gitnexus` → `pnpm dlx` → `npx`) at call time and
// runs it, inheriting stdio and propagating the child's exit code. This lets the
// committed skills and generated AGENTS.md/CLAUDE.md reference ONE stable,
// CLI-neutral command without baking in a package-manager assumption. `gitnexus
// analyze` drops a copy of this file at `.gitnexus/run.cjs`. Skipped on require()
// (the CLI and tests reuse the exports above), so it runs only when invoked as a
// script.
if (require.main === module) {
const gitnexusArgs = process.argv.slice(2);
const { program, args } = buildRunnerArgv(resolveInvocationMode(), gitnexusArgs);
try {
execFileSync(program, args, {
stdio: 'inherit',
windowsHide: true,
// On Windows, `npx`/`pnpm`/`gitnexus` resolve to `.cmd`/`.ps1`/`.exe`
// shims (npm, Volta, Corepack, scoop). execFileSync does not do PATHEXT
// resolution and Node refuses to spawn `.cmd`/`.bat` without a shell
// (CVE-2024-27980), so a bare program name ENOENTs. A shell lets the OS
// resolve the shim; POSIX needs no shell (direct PATH lookup works).
shell: process.platform === 'win32',
});
} catch (err) {
// Make spawn failures (resolved program absent from PATH) self-explanatory
// instead of a silent exit 1, then propagate the runner's own exit code.
if (typeof err.status !== 'number') {
process.stderr.write(`gitnexus runner: could not launch \`${program}\` — ${err.message}\n`);
}
process.exit(typeof err.status === 'number' ? err.status : 1);
}
}

View file

@ -30,6 +30,7 @@ const PLATFORM_LOGIC = [
'test/unit/setup-jsonc.test.ts',
'test/unit/setup-codex.test.ts',
'test/unit/setup-antigravity.test.ts',
'test/unit/resolve-invocation.test.ts',
'test/unit/platform-capabilities.test.ts',
'test/unit/worker-pool-windows-quarantine.test.ts',
'test/unit/lbug-pool-win-fts-probe.test.ts',
@ -84,6 +85,7 @@ const SPAWN_CLI = [
'test/integration/setup-antigravity.test.ts',
'test/integration/antigravity-hook-e2e.test.ts',
'test/unit/local-cli-subprocess.test.ts',
'test/unit/runner-exec-tail.test.ts',
];
// Worker threads tests — exercise real worker_threads which have

View file

@ -5,14 +5,16 @@ description: "Use when the user needs to run GitNexus CLI commands like analyze/
# GitNexus CLI Commands
All commands work via `npx` — no global install required.
Commands below use `node .gitnexus/run.cjs <command>` — the project-local runner `gitnexus analyze` drops next to the index. It auto-selects an available runner at call time (global `gitnexus`, else `pnpm dlx`, else `npx`), so no package-manager assumption and no global install is required.
> **Not analyzed yet, or `node .gitnexus/run.cjs` reports `Cannot find module`** (the gitignored runner is absent — e.g. a fresh clone or `git clean`)? (Re)generate it with `npx gitnexus analyze` from the project root. On **npm 11.x**, if `npx` crashes during install (`node.target is null`), install once with `npm i -g gitnexus` (then `gitnexus analyze`) or use `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939).
## Commands
### analyze — Build or refresh the index
```bash
npx gitnexus analyze
node .gitnexus/run.cjs analyze
```
Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files.
@ -28,7 +30,7 @@ Run from the project root. This parses all source files, builds the knowledge gr
### status — Check index freshness
```bash
npx gitnexus status
node .gitnexus/run.cjs status
```
Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed.
@ -36,7 +38,7 @@ Shows whether the current repo has a GitNexus index, when it was last updated, a
### clean — Delete the index
```bash
npx gitnexus clean
node .gitnexus/run.cjs clean
```
Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.
@ -49,7 +51,7 @@ Deletes the `.gitnexus/` directory and unregisters the repo from the global regi
### wiki — Generate documentation from the graph
```bash
npx gitnexus wiki
node .gitnexus/run.cjs wiki
```
Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
@ -66,7 +68,7 @@ Generates repository documentation from the knowledge graph using an LLM. Requir
### list — Show all indexed repos
```bash
npx gitnexus list
node .gitnexus/run.cjs list
```
Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.

View file

@ -22,7 +22,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
```
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
## Checklist

View file

@ -23,7 +23,7 @@ description: "Use when the user asks how code works, wants to understand archite
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
```
> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal.
> If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
## Checklist

View file

@ -15,7 +15,7 @@ For any task involving code understanding, debugging, impact analysis, or refact
2. **Match your task to a skill below** and **read that skill file**
3. **Follow the skill's workflow and checklist**
> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
> If step 1 warns the index is stale, run `node .gitnexus/run.cjs analyze` in the terminal first.
## Skills

View file

@ -23,7 +23,7 @@ description: "Use when the user wants to know what will break if they change som
4. Assess risk and report to user
```
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
## Checklist

View file

@ -26,7 +26,7 @@ description: "Use when the user wants to review a pull request, understand what
6. Summarize findings with risk assessment
```
> If "Index is stale" → run `npx gitnexus analyze` in terminal before reviewing.
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal before reviewing.
## Checklist

View file

@ -22,7 +22,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
4. Plan update order: interfaces → implementations → callers → tests
```
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
## Checklists

View file

@ -89,13 +89,17 @@ async function findGroupsContainingRegistryName(registryName: string): Promise<s
return hits;
}
function generateGitNexusContent(
export function generateGitNexusContent(
projectName: string,
stats: RepoStats,
generatedSkills?: GeneratedSkillInfo[],
groupNames?: string[],
noStats?: boolean,
skipSkills?: boolean,
// Project-relative path to the runner `gitnexus analyze` drops next to the
// index (#1945). Referenced by docs so a single CLI-neutral command resolves
// the available runner (global `gitnexus` → `pnpm dlx` → `npx`) at call time.
runnerPath: string = '.gitnexus/run.cjs',
): string {
const generatedRows =
generatedSkills && generatedSkills.length > 0
@ -127,13 +131,22 @@ function generateGitNexusContent(
|------|---------------------|
${tableBody}`
: '';
// Docs reference the project-local runner `gitnexus analyze` writes (#1945):
// a single, CLI-neutral, machine-independent command (no per-machine churn,
// #1706) that auto-selects the available runner at call time. Kept terse to
// stay under the CLAUDE.md block token budget (#856); the cli skill carries the
// full bootstrap + npm-11 fallback (`node.target is null` npx install crash).
const runner = `node ${runnerPath}`;
const bootstrapNote =
`No \`${runnerPath}\` yet? \`npx gitnexus analyze\` ` +
'(npm 11 crash → `npm i -g gitnexus`; #1939).';
return `${GITNEXUS_START_MARKER}
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows)`}. Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run \`npx gitnexus analyze\` in terminal first.
> Index stale? Run \`${runner} analyze\` from the project root — it auto-selects an available runner. ${bootstrapNote}
## Always Do
@ -163,7 +176,7 @@ ${
groupNames && groupNames.length > 0
? `## Cross-Repo Groups
This repository is listed under GitNexus **group(s): ${groupNames.join(', ')}** (see \`~/.gitnexus/groups/\`). For cross-repo analysis, use MCP tools \`impact\`, \`query\`, and \`context\` with \`repo\` set to \`@<groupName>\` or \`@<groupName>/<memberPath>\` (paths match keys in that group’s \`group.yaml\`). Use \`group_list\` / \`group_sync\` for membership and sync. From the terminal: \`npx gitnexus group list\`, \`npx gitnexus group sync <name>\`, \`npx gitnexus group impact <name> --target <symbol> --repo <group-path>\`.
This repository is listed under GitNexus **group(s): ${groupNames.join(', ')}** (see \`~/.gitnexus/groups/\`). For cross-repo analysis, use MCP tools \`impact\`, \`query\`, and \`context\` with \`repo\` set to \`@<groupName>\` or \`@<groupName>/<memberPath>\` (paths match keys in that group’s \`group.yaml\`). Use \`group_list\` / \`group_sync\` for membership and sync. From the project root: \`${runner} group list\`, \`${runner} group sync <name>\`, \`${runner} group impact <name> --target <symbol> --repo <group-path>\` (the \`${runnerPath}\` path is repo-root-relative).
`
: ''
@ -379,13 +392,36 @@ Use GitNexus tools to accomplish this task.
*/
export async function generateAIContextFiles(
repoPath: string,
_storagePath: string,
storagePath: string,
projectName: string,
stats: RepoStats,
generatedSkills?: GeneratedSkillInfo[],
options?: AIContextOptions,
): Promise<{ files: string[] }> {
const groupNames = await findGroupsContainingRegistryName(projectName);
// Drop a project-local runner next to the index (#1945) so the generated docs
// can reference one CLI-neutral command that resolves the available runner at
// call time. It is a copy of the canonical self-contained resolver, which the
// CLI and hooks already share; failure to copy is non-fatal (docs carry a
// bootstrap fallback). `runnerPath` is project-relative with POSIX separators
// so the emitted command is identical across platforms.
const runnerPath = path.relative(repoPath, path.join(storagePath, 'run.cjs')).replace(/\\/g, '/');
try {
const runnerSrc = path.join(
__dirname,
'..',
'..',
'hooks',
'claude',
'resolve-analyze-cmd.cjs',
);
await fs.mkdir(storagePath, { recursive: true });
await fs.copyFile(runnerSrc, path.join(storagePath, 'run.cjs'));
} catch (err) {
logger.warn(`Could not write GitNexus runner to ${runnerPath}: ${String(err)}`);
}
const content = generateGitNexusContent(
projectName,
stats,
@ -393,6 +429,7 @@ export async function generateAIContextFiles(
groupNames,
options?.noStats,
options?.skipSkills,
runnerPath,
);
const createdFiles: string[] = [];

View file

@ -35,6 +35,7 @@ import fs from 'fs/promises';
import { cliError } from './cli-message.js';
import { formatElapsed } from './format-elapsed.js';
import { isHfDownloadFailure } from '../core/embeddings/hf-env.js';
import { warnIfNpm11NpxRisk } from './resolve-invocation.js';
// Capture stderr.write at module load BEFORE anything (LadybugDB native
// init, progress bar, console redirection) can monkey-patch it. The
@ -617,6 +618,11 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
// a stack trace and a non-zero exit code instead of a silent exit 0.
installFatalHandlers();
// npm-11 npx-crash nudge (#1939). Runs here, after the heap re-exec guard,
// so it fires once in the working process and never on the lazy-startup path
// of other commands (e.g. `gitnexus mcp`).
warnIfNpm11NpxRisk();
// Snapshot the GITNEXUS_* env vars that the impl writes for downstream
// consumption, so they don't leak across `analyzeCommand` invocations in
// programmatic callers (tests, long-running hosts). `process.exit(0)` on

View file

@ -0,0 +1,98 @@
/**
* npm 11.x npx-install-crash nudge for the `analyze` command (#1939).
*
* The gitnexus/pnpm/npx selection itself lives in the canonical hook helper
* (hooks/claude/resolve-analyze-cmd.cjs) — self-contained CJS because the copied
* hook runtime cannot import from the package. We reuse it here via createRequire
* instead of re-implementing it, so there is one source of truth for the
* invocation decision. This module adds only the npm-version probe and the
* warning, which are CLI-only. The relative path resolves identically from
* src/cli/ (tsx, vitest) and dist/cli/ (shipped), since both sit one level under
* the package root and `hooks/` is published.
*/
import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';
type InvocationMode = 'gitnexus' | 'pnpm' | 'npx';
interface InvocationResolver {
// `probe` is injectable in the cjs (defaults to the real PATH probe) so the
// preference order is unit-testable without spawning; the CLI calls it with
// no argument.
resolveInvocationMode: (
probe?: (command: string, gitnexusWrapper?: boolean) => string | null,
) => InvocationMode;
formatDocumentationDlxCommand: (
gitnexusArgs: string,
options?: { embeddings?: boolean },
) => string;
NPX_REF: string;
}
const { resolveInvocationMode, formatDocumentationDlxCommand, NPX_REF } = createRequire(
import.meta.url,
// `require()` returns `any`; go through `unknown` so the cast reads as an
// explicit narrowing to the subset this module uses, not a claim that the
// cjs's full export shape is known here. The drift guard below verifies it.
)('../../hooks/claude/resolve-analyze-cmd.cjs') as unknown as InvocationResolver;
// Fail loud at module load if the canonical cjs export shape drifts (e.g. a
// renamed export), rather than as a late TypeError inside warnIfNpm11NpxRisk.
if (
typeof resolveInvocationMode !== 'function' ||
typeof formatDocumentationDlxCommand !== 'function' ||
typeof NPX_REF !== 'string'
) {
throw new Error(
'resolve-analyze-cmd.cjs must export resolveInvocationMode (function), formatDocumentationDlxCommand (function), and NPX_REF (string)',
);
}
export { NPX_REF };
// Re-implemented here (rather than reusing the cjs export) so vitest's
// `vi.mock('node:child_process')` intercepts it — the cjs uses bare
// `require('child_process')`, which the mock cannot reach. Timeout matches the
// cjs PROBE_TIMEOUT_MS (1s) so this CLI probe shares the same hook-budget cap;
// `npm --version` is a sub-second local call.
export function getNpmMajorVersion(): number | null {
try {
const output = execFileSync('npm', ['--version'], {
encoding: 'utf-8',
timeout: 1000,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
// Windows `npm` is a `.cmd` shim; without a shell execFileSync ENOENTs
// (CVE-2024-27980) and the npm-11 npx-crash warning below would never
// fire on Windows. Mirrors probeVersion in resolve-analyze-cmd.cjs.
shell: process.platform === 'win32',
});
// Read the first version-shaped line so a Corepack/update banner on stdout
// doesn't defeat the parse (mirrors the cjs probeVersion hardening).
const major = output
.split('\n')
.map((l) => l.trim())
.find((l) => /^v?\d+\./.test(l))
?.match(/^v?(\d+)\./);
return major ? Number(major[1]) : null;
} catch {
return null;
}
}
/**
* One-line stderr nudge when an npm 11+ user is on the npx install path (#1939).
* Skipped when a global `gitnexus` or `pnpm` is already preferred, so it never
* nags users who are not exposed to the npx/arborist crash.
*/
export function warnIfNpm11NpxRisk(): void {
if (resolveInvocationMode() !== 'npx') return;
const major = getNpmMajorVersion();
if (major === null || major < 11) return;
process.stderr.write(
`Warning: npm ${major}.x can crash while installing gitnexus via npx ` +
`(npm/arborist "node.target is null"). Prefer: ${formatDocumentationDlxCommand('analyze')} ` +
`or npm install -g ${NPX_REF}. See https://github.com/abhigyanpatwari/GitNexus/issues/1939\n`,
);
}

View file

@ -33,7 +33,44 @@ if (typeof _pkg.version !== 'string' || !_pkg.version) {
'gitnexus/package.json#version is missing or not a string — cannot generate MCP fallback config.',
);
}
const NPX_REF = `gitnexus@${_pkg.version}`;
// Version-pinned ref for the persisted MCP entry — deliberately distinct from
// the cjs's exported `gitnexus@latest` hint ref (resolve-analyze-cmd.cjs); the
// two are not unified (see the comment above and that file's MCP_PINNED_REF).
const MCP_PINNED_REF = `gitnexus@${_pkg.version}`;
/**
* Build the `command` string written into an editor's hook settings, which the
* editor shell-evaluates. `hookPath` is already forward-slash-normalized.
*
* On POSIX, single-quote the path: a single-quoted shell string expands nothing,
* so spaces and metacharacters ($, backtick, ;, |, &, newline, parens) in the
* install path cannot run as commands. The only character needing escaping
* inside single quotes is the single quote, via the standard `'\''` idiom
* (close, literal-quote, reopen). The previous double-quoted `node "..."` form
* left $/backtick live — a code-execution risk for an adversarial $HOME.
*
* On Windows, filenames cannot contain these POSIX metacharacters and the path
* is forward-slashed, so keep the double-quoted form with backslash-then-quote
* escaping (CodeQL js/incomplete-sanitization safe ordering).
*/
export function formatHookCommand(
hookPath: string,
isWindows = process.platform === 'win32',
): string {
if (isWindows) {
const escaped = hookPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
return `node "${escaped}"`;
}
return `node '${hookPath.replace(/'/g, "'\\''")}'`;
}
// The exact source line each hook adapter ships, rewritten at install time to
// point cliPath at the installed CLI. Kept as a named constant so the install
// patch and its drift guard reference one string — if the adapter source ever
// changes this literal, the guard records an actionable error instead of
// silently shipping a hook with an unresolved relative cliPath.
const CLI_PATH_SOURCE_LITERAL =
"let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');";
interface SetupResult {
configured: string[];
@ -99,12 +136,12 @@ function getMcpEntry() {
if (process.platform === 'win32') {
return {
command: 'cmd',
args: ['/c', 'npx', '-y', NPX_REF, 'mcp'],
args: ['/c', 'npx', '-y', MCP_PINNED_REF, 'mcp'],
};
}
return {
command: 'npx',
args: ['-y', NPX_REF, 'mcp'],
args: ['-y', MCP_PINNED_REF, 'mcp'],
};
}
@ -120,9 +157,9 @@ function getOpenCodeMcpEntry() {
}
if (process.platform === 'win32') {
return { type: 'local', command: ['cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'] };
return { type: 'local', command: ['cmd', '/c', 'npx', '-y', MCP_PINNED_REF, 'mcp'] };
}
return { type: 'local', command: ['npx', '-y', NPX_REF, 'mcp'] };
return { type: 'local', command: ['npx', '-y', MCP_PINNED_REF, 'mcp'] };
}
/**
@ -334,6 +371,48 @@ async function mergeHooksJsonc(
return true;
}
const HOOK_HELPERS = [
'hook-lock.cjs',
'hook-db-lock-probe.cjs',
'win-rm-list-json.ps1',
'resolve-analyze-cmd.cjs',
] as const;
// win-rm-list-json.ps1 is best-effort: it is read (not require()'d) by
// hook-db-lock-probe.cjs only on Windows, and that probe fails open when the
// script is absent. Every other helper is top-level require()'d by the adapters,
// so its absence crashes the installed hook — those are the ones a failed copy
// must gate hook registration on (see copyHookHelpers' return value).
const BEST_EFFORT_HOOK_HELPERS = new Set<string>(['win-rm-list-json.ps1']);
/**
* Copy the shared hook helpers from `srcDir` into `destDir`. The adapters
* top-level `require()` the `.cjs` helpers, so a missing required helper makes
* the installed hook crash with MODULE_NOT_FOUND. A failed copy is recorded as a
* setup error, and the names of any failed REQUIRED helpers are returned so the
* caller can fail closed (skip hook registration) instead of registering a hook
* that crashes at runtime. `win-rm-list-json.ps1` is best-effort — its absence is
* recorded but does not gate registration. Both the Claude and Antigravity
* install paths copy this same list from hooks/claude/ (the canonical source).
*/
export async function copyHookHelpers(
srcDir: string,
destDir: string,
label: string,
result: SetupResult,
): Promise<string[]> {
const failedRequired: string[] = [];
for (const helper of HOOK_HELPERS) {
try {
await fs.copyFile(path.join(srcDir, helper), path.join(destDir, helper));
} catch {
result.errors.push(`${label}: failed to copy ${helper} — hook may crash at runtime`);
if (!BEST_EFFORT_HOOK_HELPERS.has(helper)) failedRequired.push(helper);
}
}
return failedRequired;
}
/**
* Install GitNexus hooks to ~/.claude/settings.json for Claude Code.
* Merges hook config without overwriting existing hooks, preserving
@ -361,49 +440,44 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
const resolvedCli = path.join(__dirname, '..', 'cli', 'index.js');
const normalizedCli = path.resolve(resolvedCli).replace(/\\/g, '/');
const jsonCli = JSON.stringify(normalizedCli);
content = content.replace(
"let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');",
`let cliPath = ${jsonCli};`,
);
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.',
);
}
content = content.replace(CLI_PATH_SOURCE_LITERAL, `let cliPath = ${jsonCli};`);
await fs.writeFile(dest, content, 'utf-8');
} catch {
// Script not found in source — skip
}
// Fail closed: registering the hook without its adapter would crash on every
// tool invocation. Mirrors the Antigravity adapter guard below (this path
// previously registered regardless of whether the adapter wrote).
try {
await fs.copyFile(
path.join(pluginHooksPath, 'hook-lock.cjs'),
path.join(destHooksDir, 'hook-lock.cjs'),
);
await fs.access(dest);
} catch {
// Helper not found in source — skip
result.errors.push(
'Claude Code hooks: adapter script was not installed — skipping hook registration',
);
return;
}
try {
await fs.copyFile(
path.join(pluginHooksPath, 'hook-db-lock-probe.cjs'),
path.join(destHooksDir, 'hook-db-lock-probe.cjs'),
const failedRequired = await copyHookHelpers(
pluginHooksPath,
destHooksDir,
'Claude Code hooks',
result,
);
if (failedRequired.length > 0) {
result.errors.push(
`Claude Code hooks: required helper(s) ${failedRequired.join(', ')} failed to copy — skipping hook registration`,
);
} catch {
// Helper not found in source — skip
}
try {
await fs.copyFile(
path.join(pluginHooksPath, 'win-rm-list-json.ps1'),
path.join(destHooksDir, 'win-rm-list-json.ps1'),
);
} catch {
// Helper not found in source — skip
return;
}
const hookPath = path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/');
// Escape backslashes FIRST, then quotes (CodeQL js/incomplete-sanitization).
// The previous shape `replace(/"/g, '\\"')` alone would let `path\with"quote`
// become `path\with\"quote`, where the trailing `\` before `"` could
// unescape the quote inside the surrounding double-quoted shell context.
const escapedHookPath = hookPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const hookCmd = `node "${escapedHookPath}"`;
const hookCmd = formatHookCommand(hookPath);
// Check which hook events need entries (idempotent: skip if already registered)
const parsed = await (async () => {
@ -566,10 +640,12 @@ async function installAntigravityHooks(result: SetupResult): Promise<void> {
const resolvedCli = path.join(__dirname, '..', 'cli', 'index.js');
const normalizedCli = path.resolve(resolvedCli).replace(/\\/g, '/');
const jsonCli = JSON.stringify(normalizedCli);
content = content.replace(
"let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');",
`let cliPath = ${jsonCli};`,
);
if (!content.includes(CLI_PATH_SOURCE_LITERAL)) {
result.errors.push(
'Antigravity hooks: gitnexus-antigravity-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};`);
await fs.writeFile(adapterDest, content, 'utf-8');
} catch {
// Adapter not found in source — skip
@ -591,19 +667,21 @@ async function installAntigravityHooks(result: SetupResult): Promise<void> {
// required by hook-db-lock-probe.cjs on Windows — without it, the MCP
// server ownership probe silently fails open and the hook may contend
// with the MCP server on the LadybugDB.
for (const helper of ['hook-lock.cjs', 'hook-db-lock-probe.cjs', 'win-rm-list-json.ps1']) {
try {
await fs.copyFile(path.join(pluginClaudeDir, helper), path.join(destHooksDir, helper));
} catch {
result.errors.push(
`Antigravity hooks: failed to copy ${helper} — hook may crash at runtime`,
);
}
const failedRequired = await copyHookHelpers(
pluginClaudeDir,
destHooksDir,
'Antigravity hooks',
result,
);
if (failedRequired.length > 0) {
result.errors.push(
`Antigravity hooks: required helper(s) ${failedRequired.join(', ')} failed to copy — skipping hook registration`,
);
return;
}
const hookPath = path.join(destHooksDir, 'gitnexus-antigravity-hook.cjs').replace(/\\/g, '/');
const escapedHookPath = hookPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const hookCmd = `node "${escapedHookPath}"`;
const hookCmd = formatHookCommand(hookPath);
const parsed = await (async () => {
try {

View file

@ -63,7 +63,12 @@ beforeAll(async () => {
if (!fs.existsSync(installedHook)) {
throw new Error(`Antigravity adapter was not installed at ${installedHook}`);
}
for (const helper of ['hook-lock.cjs', 'hook-db-lock-probe.cjs', 'win-rm-list-json.ps1']) {
for (const helper of [
'hook-lock.cjs',
'hook-db-lock-probe.cjs',
'win-rm-list-json.ps1',
'resolve-analyze-cmd.cjs',
]) {
const helperPath = path.join(path.dirname(installedHook), helper);
if (!fs.existsSync(helperPath)) {
throw new Error(`Helper not installed: ${helperPath}`);
@ -97,19 +102,24 @@ describe('antigravity hook adapter e2e', () => {
JSON.stringify({ lastCommit: 'a'.repeat(40), stats: {} }),
);
const result = runHook(installedHook, {
hook_event_name: 'AfterTool',
tool_name: 'run_shell_command',
tool_input: { command: 'git commit -m "test"' },
tool_response: { llmContent: '[committed]' },
cwd: tmpDir,
});
const result = runHook(
installedHook,
{
hook_event_name: 'AfterTool',
tool_name: 'run_shell_command',
tool_input: { command: 'git commit -m "test"' },
tool_response: { llmContent: '[committed]' },
cwd: tmpDir,
},
tmpDir,
{ env: { ...process.env, GITNEXUS_INVOCATION: 'npx' } },
);
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.hookEventName).toBe('AfterTool');
expect(output!.additionalContext).toContain('index is stale');
expect(output!.additionalContext).toContain('npx gitnexus analyze');
expect(output!.additionalContext).toContain('npx gitnexus@latest analyze');
// Mirror to stderr so terminal users see the hint even when the agent
// discards additionalContext
@ -148,17 +158,22 @@ describe('antigravity hook adapter e2e', () => {
}),
);
const result = runHook(installedHook, {
hook_event_name: 'AfterTool',
tool_name: 'run_shell_command',
tool_input: { command: 'git commit -m "x"' },
tool_response: { llmContent: '[ok]' },
cwd: tmpDir,
});
const result = runHook(
installedHook,
{
hook_event_name: 'AfterTool',
tool_name: 'run_shell_command',
tool_input: { command: 'git commit -m "x"' },
tool_response: { llmContent: '[ok]' },
cwd: tmpDir,
},
tmpDir,
{ env: { ...process.env, GITNEXUS_INVOCATION: 'npx' } },
);
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.additionalContext).toContain('--embeddings');
expect(output!.additionalContext).toContain('npx gitnexus@latest analyze --embeddings');
});
it('treats missing meta.json as stale', () => {

View file

@ -66,18 +66,48 @@ describe.each(HOOKS)('hooks e2e ($name)', ({ name, path: hookPath }) => {
JSON.stringify({ lastCommit: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', stats: {} }),
);
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "test"' },
tool_output: { exit_code: 0 },
cwd: tmpDir,
});
const result = runHook(
hookPath,
{
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "test"' },
tool_output: { exit_code: 0 },
cwd: tmpDir,
},
tmpDir,
{ env: { ...process.env, GITNEXUS_INVOCATION: 'npx' } },
);
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.additionalContext).toContain('stale');
expect(output!.additionalContext).toContain('npx gitnexus analyze');
expect(output!.additionalContext).toContain('npx gitnexus@latest analyze');
});
it('prefers pnpm dlx when GITNEXUS_INVOCATION=pnpm', () => {
fs.writeFileSync(
path.join(gitNexusDir, 'meta.json'),
JSON.stringify({ lastCommit: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', stats: {} }),
);
const result = runHook(
hookPath,
{
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "test"' },
tool_output: { exit_code: 0 },
cwd: tmpDir,
},
tmpDir,
{ env: { ...process.env, GITNEXUS_INVOCATION: 'pnpm' } },
);
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.additionalContext).toContain('--allow-build=@ladybugdb/core');
expect(output!.additionalContext).toContain('gitnexus@latest analyze');
});
it('stays silent when meta.json lastCommit matches HEAD', () => {
@ -116,17 +146,22 @@ describe.each(HOOKS)('hooks e2e ($name)', ({ name, path: hookPath }) => {
}),
);
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "test"' },
tool_output: { exit_code: 0 },
cwd: tmpDir,
});
const result = runHook(
hookPath,
{
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "test"' },
tool_output: { exit_code: 0 },
cwd: tmpDir,
},
tmpDir,
{ env: { ...process.env, GITNEXUS_INVOCATION: 'npx' } },
);
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.additionalContext).toContain('--embeddings');
expect(output!.additionalContext).toContain('npx gitnexus@latest analyze --embeddings');
});
it('treats missing meta.json as stale', () => {

View file

@ -1,8 +1,8 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { generateAIContextFiles } from '../../src/cli/ai-context.js';
import { generateAIContextFiles, generateGitNexusContent } from '../../src/cli/ai-context.js';
describe('generateAIContextFiles', () => {
let tmpDir: string;
@ -93,6 +93,80 @@ describe('generateAIContextFiles', () => {
}
});
it('emits the project-local runner command and drops .gitnexus/run.cjs regardless of mode (#1945)', async () => {
const subDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-analyze-cmd-test-'));
const subStorage = path.join(subDir, '.gitnexus');
await fs.mkdir(subStorage, { recursive: true });
const prior = process.env.GITNEXUS_INVOCATION;
try {
// Force a mode whose machine-resolved command (`gitnexus analyze`) differs
// from the emitted string, so this fails loudly if generation ever goes
// back to resolving the command per-machine instead of pointing at the
// fixed, CLI-neutral project-local runner.
process.env.GITNEXUS_INVOCATION = 'gitnexus';
const stats = { nodes: 50, edges: 100, processes: 5 };
await generateAIContextFiles(subDir, subStorage, 'CmdProject', stats);
// The runner is copied next to the index so the emitted command resolves.
const runner = await fs.readFile(path.join(subStorage, 'run.cjs'), 'utf-8');
expect(runner).toContain('buildRunnerArgv'); // it's the real resolver copy
for (const f of ['CLAUDE.md', 'AGENTS.md']) {
const content = await fs.readFile(path.join(subDir, f), 'utf-8');
// Primary command is the fixed project-local runner, not machine-resolved.
expect(content).toContain('`node .gitnexus/run.cjs analyze`');
expect(content).not.toContain('run `gitnexus analyze`'); // no machine-resolved leak
// Bootstrap path (for a not-yet-analyzed checkout) + npm-11 escape hatch.
expect(content).toContain('npx gitnexus analyze');
expect(content).toContain('1939');
}
} finally {
if (prior === undefined) delete process.env.GITNEXUS_INVOCATION;
else process.env.GITNEXUS_INVOCATION = prior;
await fs.rm(subDir, { recursive: true, force: true });
}
});
it('emits Cross-Repo Groups commands through the project-local runner (#1945)', () => {
// Exercise the groupNames>0 branch directly — the no-group path cannot
// catch a group-command regression because the block is not emitted.
const content = generateGitNexusContent(
'TestProject',
{ nodes: 50, edges: 100, processes: 5 },
undefined,
['TeamGroup'],
);
expect(content).toContain('## Cross-Repo Groups');
expect(content).toContain('node .gitnexus/run.cjs group list');
expect(content).toContain('node .gitnexus/run.cjs group sync');
expect(content).toContain('node .gitnexus/run.cjs group impact');
// Group commands must not hardcode a package manager.
expect(content).not.toMatch(/dlx gitnexus@latest group/);
expect(content).not.toMatch(/npx gitnexus group/);
});
it('degrades gracefully when the runner copy fails (#1945)', async () => {
// A read-only/full-disk storage dir must not abort generation. The copy is
// best-effort + logged; the generated docs still carry the inline bootstrap
// (`npx gitnexus analyze`) so a reader hitting the absent runner has a path.
const subDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-copyfail-'));
const subStorage = path.join(subDir, '.gitnexus');
await fs.mkdir(subStorage, { recursive: true });
const spy = vi.spyOn(fs, 'copyFile').mockRejectedValueOnce(new Error('EACCES: read-only'));
try {
const stats = { nodes: 50, edges: 100, processes: 5 };
// Must not throw despite the copy failure.
await generateAIContextFiles(subDir, subStorage, 'CopyFail', stats);
const content = await fs.readFile(path.join(subDir, 'CLAUDE.md'), 'utf-8');
expect(content).toContain('npx gitnexus analyze'); // bootstrap survives
// The runner was not written, so the file is absent.
await expect(fs.access(path.join(subStorage, 'run.cjs'))).rejects.toThrow();
} finally {
spy.mockRestore();
await fs.rm(subDir, { recursive: true, force: true });
}
});
it('keeps the load-bearing repo-specific sections in the CLAUDE.md block (#856)', async () => {
// The trimmed block must still contain everything that is genuinely
// unique per repo or load-bearing for the agent: the freshness warning,
@ -104,7 +178,7 @@ describe('generateAIContextFiles', () => {
const content = await fs.readFile(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
expect(content).toContain('If any GitNexus tool warns the index is stale');
expect(content).toContain('Index stale? Run `node .gitnexus/run.cjs analyze`');
expect(content).toContain('## Always Do');
expect(content).toContain('## Never Do');
expect(content).toContain('## Resources');

View file

@ -28,6 +28,23 @@ import { runHook, parseHookOutput } from '../utils/hook-test-helpers.js';
const CJS_HOOK = path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'gitnexus-hook.cjs');
const CJS_HOOK_LOCK = path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'hook-lock.cjs');
const RESOLVE_CJS = path.resolve(
__dirname,
'..',
'..',
'hooks',
'claude',
'resolve-analyze-cmd.cjs',
);
const RESOLVE_PLUGIN_CJS = path.resolve(
__dirname,
'..',
'..',
'..',
'gitnexus-claude-plugin',
'hooks',
'resolve-analyze-cmd.cjs',
);
const PLUGIN_HOOK = path.resolve(
__dirname,
'..',
@ -202,6 +219,8 @@ describe('Shell injection regression', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
['Resolve CJS', RESOLVE_CJS],
['Resolve Plugin', RESOLVE_PLUGIN_CJS],
] as const) {
it(`${label} hook has no shell: true in spawnSync calls`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
@ -254,6 +273,8 @@ describe('windowsHide regression', () => {
// Hook-layer files. Adding a new hook file MUST be reflected here.
const HOOK_FILES: Array<readonly [string, string]> = [
['gitnexus/hooks/claude/gitnexus-hook.cjs', CJS_HOOK],
['gitnexus/hooks/claude/resolve-analyze-cmd.cjs', RESOLVE_CJS],
['gitnexus-claude-plugin/hooks/resolve-analyze-cmd.cjs', RESOLVE_PLUGIN_CJS],
[
'gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs',
path.resolve(__dirname, '..', '..', 'hooks', 'antigravity', 'gitnexus-antigravity-hook.cjs'),

View file

@ -0,0 +1,451 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('node:child_process', () => ({
execFileSync: vi.fn(),
}));
import { execFileSync } from 'node:child_process';
import {
getNpmMajorVersion,
warnIfNpm11NpxRisk,
NPX_REF,
} from '../../src/cli/resolve-invocation.js';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';
const mockedExec = vi.mocked(execFileSync);
const cjsRequire = createRequire(import.meta.url);
const CANONICAL_CJS = path.resolve(
__dirname,
'..',
'..',
'hooks',
'claude',
'resolve-analyze-cmd.cjs',
);
const PLUGIN_CJS = path.resolve(
__dirname,
'..',
'..',
'..',
'gitnexus-claude-plugin',
'hooks',
'resolve-analyze-cmd.cjs',
);
interface CjsModule {
formatAnalyzeCommand: (
o?: { embeddings?: boolean },
deps?: { npmMajor?: number | null; pnpmMajor?: number | null; pnpmMinor?: number | null },
) => string;
formatDocumentationDlxCommand: (args: string, o?: { embeddings?: boolean }) => string;
formatPnpmAllowBuildArgs: (
o?: { embeddings?: boolean; alwaysAllowBuild?: boolean },
deps?: { pnpmMajor?: number | null; pnpmMinor?: number | null },
) => string[];
resolveInvocationMode: (
probe?: (command: string, gitnexusWrapper?: boolean) => string | null,
deps?: { npmMajor?: number | null; pnpmMajor?: number | null; pnpmPresent?: boolean },
) => 'gitnexus' | 'pnpm' | 'npx';
pickPathMatch: (
output: string,
opts?: { isWin?: boolean; gitnexusWrapper?: boolean },
) => string | null;
buildRunnerArgv: (
mode: 'gitnexus' | 'pnpm' | 'npx',
gitnexusArgs: string[],
deps?: { pnpmMajor?: number | null; pnpmMinor?: number | null },
) => { program: string; args: string[] };
NPX_REF: string;
}
// Require the real shipped artifact — the hook runtime loads this exact file, so
// the tests exercise production code, not a TypeScript mirror of it.
//
// Determinism invariant: createRequire bypasses vitest's node:child_process mock,
// so this module's resolveOnPath() would spawn a real `which`/`where`. Every test
// below avoids the live probe — by forcing GITNEXUS_INVOCATION, injecting a fake
// `probe`, or calling the pure pickPathMatch() — so results never depend on the
// host PATH. Keep new tests on one of those three paths.
const cjs = cjsRequire(CANONICAL_CJS) as CjsModule;
describe('resolve-analyze-cmd.cjs (canonical invocation resolver)', () => {
afterEach(() => {
delete process.env.GITNEXUS_INVOCATION;
});
it('standardizes the invocation ref on gitnexus@latest', () => {
expect(cjs.NPX_REF).toBe('gitnexus@latest');
});
it('formats each forced mode, with and without --embeddings', () => {
const allow = '--allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter';
const allowEmb =
'--allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter --allow-build=onnxruntime-node';
const cases = [
['gitnexus', 'gitnexus analyze', 'gitnexus analyze --embeddings'],
[
'pnpm',
`pnpm ${allow} dlx ${cjs.NPX_REF} analyze`,
`pnpm ${allowEmb} dlx ${cjs.NPX_REF} analyze --embeddings`,
],
['npx', `npx ${cjs.NPX_REF} analyze`, `npx ${cjs.NPX_REF} analyze --embeddings`],
] as const;
for (const [mode, plain, withEmbeddings] of cases) {
process.env.GITNEXUS_INVOCATION = mode;
expect(cjs.formatAnalyzeCommand(undefined, { pnpmMajor: 11 })).toBe(plain);
expect(cjs.formatAnalyzeCommand({ embeddings: true }, { pnpmMajor: 11 })).toBe(
withEmbeddings,
);
}
});
it('auto-selects global gitnexus first', () => {
expect(cjs.resolveInvocationMode(() => '/usr/local/bin/gitnexus')).toBe('gitnexus');
});
it('auto-selects pnpm on npm 11+ when pnpm is on PATH', () => {
const probe = (c: string) => (c === 'pnpm' ? '/usr/local/bin/pnpm' : null);
expect(cjs.resolveInvocationMode(probe, { npmMajor: 11 })).toBe('pnpm');
});
it('auto-selects npx on npm 10 even when pnpm is on PATH', () => {
const probe = (c: string) => (c === 'pnpm' ? '/usr/local/bin/pnpm' : null);
expect(cjs.resolveInvocationMode(probe, { npmMajor: 10 })).toBe('npx');
});
it('auto-selects pnpm when npm is absent (null injected) but pnpm is on PATH', () => {
// npmMajor:null means "npm absent" and must be honored via the `in` seam —
// not fall through to the host's real npm (npm 10.x on CI → would route npx).
const probe = (c: string) => (c === 'pnpm' ? '/usr/local/bin/pnpm' : null);
expect(cjs.resolveInvocationMode(probe, { npmMajor: null })).toBe('pnpm');
});
it('falls back to npx when npm is null-absent and pnpm is also absent', () => {
expect(cjs.resolveInvocationMode(() => null, { npmMajor: null })).toBe('npx');
});
it('falls back to npx when neither global gitnexus nor pnpm is available', () => {
expect(cjs.resolveInvocationMode(() => null, { npmMajor: 11 })).toBe('npx');
});
it('honors pnpmPresent:true — a present-but-unparseable pnpm selects pnpm, not the npx crash path', () => {
// Windows headline regression guard: when probeVersion cannot read the
// version (timeout / Corepack banner) but pnpm is on PATH, formatAnalyzeCommand
// sets pnpmPresent:true so npm-11 users still get pnpm rather than the npx crash.
expect(cjs.resolveInvocationMode(() => null, { npmMajor: 11, pnpmPresent: true })).toBe('pnpm');
});
it('honors pnpmPresent:false as explicit absence (overrides a PATH hit)', () => {
const probe = (c: string) => (c === 'pnpm' ? '/usr/local/bin/pnpm' : null);
expect(cjs.resolveInvocationMode(probe, { npmMajor: 11, pnpmPresent: false })).toBe('npx');
});
it('omits --allow-build on pnpm 9 (scripts run by default)', () => {
process.env.GITNEXUS_INVOCATION = 'pnpm';
expect(cjs.formatAnalyzeCommand(undefined, { pnpmMajor: 9 })).toBe(
`pnpm dlx ${cjs.NPX_REF} analyze`,
);
});
it('includes --allow-build (pre-dlx) on pnpm 10.x with unknown minor (conservative)', () => {
// No pnpmMinor injected → minor is null → the gate cannot prove < 10.2, so
// it conservatively emits the flags. This is the unknown-minor fallback, NOT
// real pnpm 10.0 (which reports minor=0 and is covered separately below).
process.env.GITNEXUS_INVOCATION = 'pnpm';
const allow = '--allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter';
expect(cjs.formatAnalyzeCommand(undefined, { pnpmMajor: 10 })).toBe(
`pnpm ${allow} dlx ${cjs.NPX_REF} analyze`,
);
});
it('omits --allow-build on pnpm 10.0 (the flag did not exist until 10.2)', () => {
process.env.GITNEXUS_INVOCATION = 'pnpm';
expect(cjs.formatAnalyzeCommand(undefined, { pnpmMajor: 10, pnpmMinor: 0 })).toBe(
`pnpm dlx ${cjs.NPX_REF} analyze`,
);
});
it('omits --allow-build on pnpm 10.1 (the flag was added in 10.2)', () => {
process.env.GITNEXUS_INVOCATION = 'pnpm';
expect(cjs.formatAnalyzeCommand(undefined, { pnpmMajor: 10, pnpmMinor: 1 })).toBe(
`pnpm dlx ${cjs.NPX_REF} analyze`,
);
});
it('includes --allow-build on pnpm 10.2 (the first minor that accepts the flag)', () => {
process.env.GITNEXUS_INVOCATION = 'pnpm';
const allow = '--allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter';
expect(cjs.formatAnalyzeCommand(undefined, { pnpmMajor: 10, pnpmMinor: 2 })).toBe(
`pnpm ${allow} dlx ${cjs.NPX_REF} analyze`,
);
});
it('emits --allow-build when the pnpm major is null-injected (absent/unknown)', () => {
expect(cjs.formatPnpmAllowBuildArgs({}, { pnpmMajor: null })).toEqual([
'--allow-build=@ladybugdb/core',
'--allow-build=gitnexus',
'--allow-build=tree-sitter',
]);
});
it('formatDocumentationDlxCommand always includes allow-build for committed docs', () => {
expect(cjs.formatDocumentationDlxCommand('analyze')).toContain('--allow-build=@ladybugdb/core');
expect(cjs.formatDocumentationDlxCommand('analyze')).toContain('gitnexus@latest analyze');
});
it('lets GITNEXUS_INVOCATION override the probe without consulting it', () => {
process.env.GITNEXUS_INVOCATION = 'pnpm';
const probe = vi.fn(() => '/usr/local/bin/gitnexus');
expect(cjs.resolveInvocationMode(probe)).toBe('pnpm');
expect(probe).not.toHaveBeenCalled();
});
});
describe('pickPathMatch — Windows global-shim detection', () => {
it('detects a .exe-only shim (Volta/scoop)', () => {
expect(
cjs.pickPathMatch('C:\\Users\\me\\AppData\\Local\\Volta\\bin\\gitnexus.exe\r\n', {
isWin: true,
gitnexusWrapper: true,
}),
).toBe('C:\\Users\\me\\AppData\\Local\\Volta\\bin\\gitnexus.exe');
});
it('detects an extensionless shim', () => {
expect(
cjs.pickPathMatch('C:\\tools\\gitnexus\r\n', { isWin: true, gitnexusWrapper: true }),
).toBe('C:\\tools\\gitnexus');
});
it('prefers a .cmd over an extensionless sibling', () => {
expect(
cjs.pickPathMatch('C:\\npm\\gitnexus\r\nC:\\npm\\gitnexus.cmd\r\n', {
isWin: true,
gitnexusWrapper: true,
}),
).toBe('C:\\npm\\gitnexus.cmd');
});
it('strips the CRLF carriage return from the chosen path', () => {
const bin = cjs.pickPathMatch('C:\\npm\\gitnexus.cmd\r\n', {
isWin: true,
gitnexusWrapper: true,
});
expect(bin).not.toMatch(/\r/);
expect(bin).toBe('C:\\npm\\gitnexus.cmd');
});
it('returns the first hit on non-Windows / non-wrapper lookups, null on empty', () => {
expect(cjs.pickPathMatch('/usr/local/bin/pnpm\n', { isWin: false })).toBe(
'/usr/local/bin/pnpm',
);
expect(cjs.pickPathMatch('', { isWin: true, gitnexusWrapper: true })).toBeNull();
});
it('returns the first hit for a Windows non-wrapper lookup (pnpm probe)', () => {
expect(
cjs.pickPathMatch('C:\\npm\\pnpm.cmd\r\n', { isWin: true, gitnexusWrapper: false }),
).toBe('C:\\npm\\pnpm.cmd');
});
});
describe('warnIfNpm11NpxRisk (#1939 npm-11 nudge)', () => {
afterEach(() => {
vi.clearAllMocks();
delete process.env.GITNEXUS_INVOCATION;
});
it('exposes the resolver contract the load-time guard enforces', () => {
// The module's createRequire guard throws at load if the cjs export shape
// drifts; that this module imported at all (and these hold) proves it passed.
expect(typeof NPX_REF).toBe('string');
expect(typeof getNpmMajorVersion).toBe('function');
expect(typeof warnIfNpm11NpxRisk).toBe('function');
});
it('parses the npm major version', () => {
mockedExec.mockReturnValue('11.5.2\n');
expect(getNpmMajorVersion()).toBe(11);
});
it('handles edge npm --version output (pre-release / empty / non-numeric)', () => {
mockedExec.mockReturnValue('12.0.0-pre\n');
expect(getNpmMajorVersion()).toBe(12);
mockedExec.mockReturnValue('\n');
expect(getNpmMajorVersion()).toBeNull();
mockedExec.mockReturnValue('not-a-version\n');
expect(getNpmMajorVersion()).toBeNull();
});
it('tolerates a Corepack/notice banner line before the version', () => {
mockedExec.mockReturnValue(
'Corepack is about to download https://registry.npmjs.org/npm/-/npm-11.0.0.tgz\n11.0.0\n',
);
expect(getNpmMajorVersion()).toBe(11);
});
it('passes a shell on Windows so the .cmd npm shim resolves (load-bearing for the warning)', () => {
const orig = Object.getOwnPropertyDescriptor(process, 'platform')!;
try {
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
mockedExec.mockReturnValue('11.0.0\n');
getNpmMajorVersion();
expect(mockedExec).toHaveBeenCalledWith(
'npm',
['--version'],
expect.objectContaining({ shell: true }),
);
} finally {
Object.defineProperty(process, 'platform', orig);
}
});
it('uses no shell on POSIX (direct PATH lookup)', () => {
const orig = Object.getOwnPropertyDescriptor(process, 'platform')!;
try {
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
mockedExec.mockReturnValue('11.0.0\n');
getNpmMajorVersion();
expect(mockedExec).toHaveBeenCalledWith(
'npm',
['--version'],
expect.objectContaining({ shell: false }),
);
} finally {
Object.defineProperty(process, 'platform', orig);
}
});
it('warns on the npm 11+ npx path', () => {
process.env.GITNEXUS_INVOCATION = 'npx';
mockedExec.mockReturnValue('11.0.0\n');
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
warnIfNpm11NpxRisk();
expect(write).toHaveBeenCalledTimes(1);
expect(String(write.mock.calls[0]?.[0])).toContain('node.target is null');
expect(String(write.mock.calls[0]?.[0])).toContain('--allow-build=@ladybugdb/core');
expect(String(write.mock.calls[0]?.[0])).toContain(`gitnexus@latest analyze`);
write.mockRestore();
});
it('does not warn when a global gitnexus or pnpm is preferred', () => {
process.env.GITNEXUS_INVOCATION = 'pnpm';
mockedExec.mockReturnValue('11.0.0\n');
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
warnIfNpm11NpxRisk();
expect(write).not.toHaveBeenCalled();
write.mockRestore();
});
it('does not warn when a global gitnexus is preferred', () => {
process.env.GITNEXUS_INVOCATION = 'gitnexus';
mockedExec.mockReturnValue('11.0.0\n');
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
warnIfNpm11NpxRisk();
expect(write).not.toHaveBeenCalled();
write.mockRestore();
});
it('does not warn when npm is older than 11', () => {
process.env.GITNEXUS_INVOCATION = 'npx';
mockedExec.mockReturnValue('10.9.0\n');
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
warnIfNpm11NpxRisk();
expect(write).not.toHaveBeenCalled();
write.mockRestore();
});
it('does not warn when npm is absent', () => {
process.env.GITNEXUS_INVOCATION = 'npx';
mockedExec.mockImplementation(() => {
throw new Error('missing');
});
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
warnIfNpm11NpxRisk();
expect(write).not.toHaveBeenCalled();
write.mockRestore();
});
});
describe('buildRunnerArgv (project-local runner exec, #1945)', () => {
it('passes gitnexus args straight through for the global-binary mode', () => {
expect(cjs.buildRunnerArgv('gitnexus', ['group', 'list'])).toEqual({
program: 'gitnexus',
args: ['group', 'list'],
});
});
it('prefixes the registry ref for npx mode', () => {
expect(cjs.buildRunnerArgv('npx', ['analyze'])).toEqual({
program: 'npx',
args: ['gitnexus@latest', 'analyze'],
});
});
it('builds the pre-`dlx` --allow-build invocation for pnpm mode', () => {
// Inject a pnpm version >= 10.2 so the allow-build flags are emitted without
// a live `pnpm --version` probe.
const { program, args } = cjs.buildRunnerArgv('pnpm', ['analyze'], {
pnpmMajor: 10,
pnpmMinor: 14,
});
expect(program).toBe('pnpm');
// Flags must precede `dlx` (ERR_PNPM_SPEC_NOT_SUPPORTED otherwise, #1939).
const dlxIdx = args.indexOf('dlx');
expect(dlxIdx).toBeGreaterThan(0);
expect(args.slice(0, dlxIdx)).toEqual([
'--allow-build=@ladybugdb/core',
'--allow-build=gitnexus',
'--allow-build=tree-sitter',
]);
expect(args.slice(dlxIdx)).toEqual(['dlx', 'gitnexus@latest', 'analyze']);
});
it('widens the pnpm allow-build set when --embeddings is requested', () => {
const { args } = cjs.buildRunnerArgv('pnpm', ['analyze', '--embeddings'], {
pnpmMajor: 10,
pnpmMinor: 14,
});
expect(args).toContain('--allow-build=onnxruntime-node');
});
it('widens the allow-build set for the --embeddings=N equals form too', () => {
const { args } = cjs.buildRunnerArgv('pnpm', ['analyze', '--embeddings=5000'], {
pnpmMajor: 10,
pnpmMinor: 14,
});
expect(args).toContain('--allow-build=onnxruntime-node');
});
it('omits onnxruntime-node when --embeddings is absent', () => {
const { args } = cjs.buildRunnerArgv('pnpm', ['analyze'], { pnpmMajor: 10, pnpmMinor: 14 });
expect(args).not.toContain('--allow-build=onnxruntime-node');
});
});
describe('resolve-analyze-cmd.cjs parity', () => {
it('keeps the two CJS hook copies byte-identical', () => {
expect(readFileSync(CANONICAL_CJS, 'utf-8')).toBe(readFileSync(PLUGIN_CJS, 'utf-8'));
});
});
describe('CLI module-load posture (R3/R4 regression guard)', () => {
const cliDir = path.resolve(__dirname, '..', '..', 'src', 'cli');
it('does not probe invocation hints at index.ts module load (#207/#1383)', () => {
const indexSrc = readFileSync(path.join(cliDir, 'index.ts'), 'utf-8');
// Every command — including the `gitnexus mcp` stdio server — pays index.ts
// module load. warnIfNpm11NpxRisk()/PATH probing must stay out of module
// scope, or it reintroduces the startup-spawn regression (#207, #1383).
expect(indexSrc).not.toMatch(/warnIfNpm11NpxRisk/);
expect(indexSrc).not.toMatch(/resolve-invocation/);
});
it('wires the npm-11 warning into the analyze command instead', () => {
const analyzeSrc = readFileSync(path.join(cliDir, 'analyze.ts'), 'utf-8');
expect(analyzeSrc).toMatch(/warnIfNpm11NpxRisk\(\)/);
});
});

View file

@ -0,0 +1,91 @@
import { describe, it, expect } from 'vitest';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, chmodSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
// Exercises the `require.main === module` direct-exec entrypoint of the runner
// (#1945) end-to-end: the pure `buildRunnerArgv` is covered in
// resolve-invocation.test.ts, but the spawn + exit-code propagation + missing-
// runner diagnostic only live in the exec tail. We spawn a real child `node`
// against the canonical run.cjs with GITNEXUS_INVOCATION forcing a mode (so no
// live PATH probe) and a fake `gitnexus` on PATH.
//
// The POSIX cases stage a shebang shell script; the Windows case below stages a
// `.cmd` shim to exercise the Windows-specific branch (shell:true so cmd.exe
// resolves `.cmd`/`.ps1` shims via PATHEXT). This file is registered in
// scripts/cross-platform-tests.ts (SPAWN_CLI) so the windows-latest CI job
// actually runs the Windows case; the POSIX cases self-skip there.
const CANONICAL_CJS = path.resolve(
__dirname,
'..',
'..',
'hooks',
'claude',
'resolve-analyze-cmd.cjs',
);
const onPosix = process.platform !== 'win32';
describe('run.cjs direct-exec entrypoint (#1945)', () => {
it.skipIf(!onPosix)(
'execs the resolved runner, passes args through, and propagates its exit code',
() => {
const dir = mkdtempSync(path.join(os.tmpdir(), 'gn-runner-'));
const fake = path.join(dir, 'gitnexus');
// Fake global `gitnexus` that echoes its argv and exits 42.
writeFileSync(fake, '#!/bin/sh\necho "fake-gitnexus $@"\nexit 42\n');
chmodSync(fake, 0o755);
const res = spawnSync(process.execPath, [CANONICAL_CJS, 'analyze', '--foo'], {
env: {
...process.env,
GITNEXUS_INVOCATION: 'gitnexus',
PATH: `${dir}:${process.env.PATH}`,
},
encoding: 'utf-8',
});
expect(res.status).toBe(42); // exit code propagated, not swallowed
expect(res.stdout).toContain('fake-gitnexus analyze --foo'); // args passthrough + inherited stdio
},
);
it.skipIf(!onPosix)(
'prints a diagnostic and exits 1 when the resolved runner is absent from PATH',
() => {
const dir = mkdtempSync(path.join(os.tmpdir(), 'gn-runner-empty-'));
const res = spawnSync(process.execPath, [CANONICAL_CJS, 'analyze'], {
// Force gitnexus mode but give an empty PATH so the spawn ENOENTs.
env: { ...process.env, GITNEXUS_INVOCATION: 'gitnexus', PATH: dir },
encoding: 'utf-8',
});
expect(res.status).toBe(1);
expect(res.stderr).toContain('could not launch');
},
);
it.skipIf(onPosix)(
'resolves a .cmd shim via the Windows shell branch, passing args and exit code',
() => {
const dir = mkdtempSync(path.join(os.tmpdir(), 'gn-runner-win-'));
const fake = path.join(dir, 'gitnexus.cmd');
// Fake global `gitnexus` .cmd shim: echoes its argv and exits 42. The exec
// tail's `shell: process.platform === 'win32'` routes through cmd.exe,
// which resolves bare `gitnexus` → `gitnexus.cmd` via PATHEXT.
writeFileSync(fake, '@echo off\r\necho fake-gitnexus %*\r\nexit /b 42\r\n');
const res = spawnSync(process.execPath, [CANONICAL_CJS, 'analyze', '--foo'], {
env: {
...process.env,
GITNEXUS_INVOCATION: 'gitnexus',
PATH: `${dir};${process.env.PATH}`,
},
encoding: 'utf-8',
});
expect(res.status).toBe(42); // exit code propagated through the shell
expect(res.stdout).toContain('fake-gitnexus analyze --foo'); // args passthrough
},
);
});

View file

@ -246,6 +246,9 @@ describe('setupAntigravity', () => {
// Required by hook-db-lock-probe.cjs on Windows; without it the MCP
// server ownership probe silently fails open.
await expect(fs.access(path.join(destDir, 'win-rm-list-json.ps1'))).resolves.toBeUndefined();
// The adapter top-level require()s this; the production install path must
// co-locate it next to the adapter (symmetric with the Claude install).
await expect(fs.access(path.join(destDir, 'resolve-analyze-cmd.cjs'))).resolves.toBeUndefined();
});
it('installs skills under ~/.gemini/antigravity/skills/<name>/SKILL.md', async () => {
@ -294,6 +297,7 @@ const ADAPTER_SRC = path.join(
const LOCK_SRC = path.join(PROJECT_ROOT, 'hooks', 'claude', 'hook-lock.cjs');
const PROBE_SRC = path.join(PROJECT_ROOT, 'hooks', 'claude', 'hook-db-lock-probe.cjs');
const WIN_RM_SRC = path.join(PROJECT_ROOT, 'hooks', 'claude', 'win-rm-list-json.ps1');
const RESOLVE_SRC = path.join(PROJECT_ROOT, 'hooks', 'claude', 'resolve-analyze-cmd.cjs');
async function stageAdapter(): Promise<string> {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-antigravity-adapter-'));
@ -304,6 +308,9 @@ async function stageAdapter(): Promise<string> {
// the lock probe silently fails open and the adapter's Windows DB-lock path
// would be untested in child-process smoke tests.
await fs.copyFile(WIN_RM_SRC, path.join(tmp, 'win-rm-list-json.ps1'));
// The adapter top-level `require('./resolve-analyze-cmd.cjs')`s this helper;
// without staging it the spawned adapter crashes with MODULE_NOT_FOUND.
await fs.copyFile(RESOLVE_SRC, path.join(tmp, 'resolve-analyze-cmd.cjs'));
return path.join(tmp, 'gitnexus-antigravity-hook.cjs');
}
@ -311,17 +318,27 @@ function runAdapter(
hookPath: string,
input: Record<string, any>,
cwd?: string,
env?: Record<string, string>,
): { stdout: string; stderr: string; status: number | null } {
const result = spawnSync(process.execPath, [hookPath], {
input: JSON.stringify(input),
encoding: 'utf-8',
timeout: 10000,
cwd,
env: env ? { ...process.env, ...env } : process.env,
stdio: ['pipe', 'pipe', 'pipe'],
});
return { stdout: result.stdout || '', stderr: result.stderr || '', status: result.status };
}
// A staged adapter that fails to load (e.g. a missing sibling helper) exits
// non-zero and prints MODULE_NOT_FOUND — a state that otherwise masquerades as
// "no stdout" in the silent-path tests below. Assert the process actually ran.
function expectAdapterLoaded(stderr: string, status: number | null): void {
expect(status).toBe(0);
expect(stderr).not.toMatch(/MODULE_NOT_FOUND|Cannot find module/);
}
describe('gitnexus-antigravity-hook adapter', () => {
let adapter: string;
let workdir: string;
@ -337,7 +354,7 @@ describe('gitnexus-antigravity-hook adapter', () => {
});
it('AfterTool with no .gitnexus/ produces no stdout', async () => {
const { stdout } = runAdapter(
const { stdout, stderr, status } = runAdapter(
adapter,
{
hook_event_name: 'AfterTool',
@ -349,10 +366,11 @@ describe('gitnexus-antigravity-hook adapter', () => {
workdir,
);
expect(stdout.trim()).toBe('');
expectAdapterLoaded(stderr, status);
});
it('AfterTool ignores unrelated tools silently', async () => {
const { stdout, stderr } = runAdapter(
const { stdout, stderr, status } = runAdapter(
adapter,
{
hook_event_name: 'AfterTool',
@ -365,6 +383,7 @@ describe('gitnexus-antigravity-hook adapter', () => {
);
expect(stdout.trim()).toBe('');
expect(stderr).not.toMatch(/\[GitNexus\]/);
expectAdapterLoaded(stderr, status);
});
it('AfterTool ignores non-git run_shell_command silently', async () => {
@ -376,7 +395,7 @@ describe('gitnexus-antigravity-hook adapter', () => {
'utf-8',
);
const { stdout, stderr } = runAdapter(
const { stdout, stderr, status } = runAdapter(
adapter,
{
hook_event_name: 'AfterTool',
@ -389,6 +408,7 @@ describe('gitnexus-antigravity-hook adapter', () => {
);
expect(stdout.trim()).toBe('');
expect(stderr).not.toMatch(/\[GitNexus\]/);
expectAdapterLoaded(stderr, status);
});
it('AfterTool emits stale-index hint after a successful git commit', async () => {
@ -418,6 +438,10 @@ describe('gitnexus-antigravity-hook adapter', () => {
cwd: workdir,
},
workdir,
// Force a deterministic invocation mode: the emitted analyze command
// varies by what's installed on each CI runner (gitnexus/pnpm/npx), and
// only the `gitnexus` mode yields the bare `gitnexus analyze` form.
{ GITNEXUS_INVOCATION: 'gitnexus' },
);
// Hint surfaces both via the agent-visible channel and stderr (terminal).
@ -438,7 +462,7 @@ describe('gitnexus-antigravity-hook adapter', () => {
'utf-8',
);
const { stdout } = runAdapter(
const { stdout, stderr, status } = runAdapter(
adapter,
{
hook_event_name: 'AfterTool',
@ -450,6 +474,7 @@ describe('gitnexus-antigravity-hook adapter', () => {
workdir,
);
expect(stdout.trim()).toBe('');
expectAdapterLoaded(stderr, status);
});
it('ignores unknown tool names without crashing', async () => {

View file

@ -8,7 +8,7 @@ import { createRequire } from 'module';
// so the test never goes stale on a release bump.
const PKG_VERSION = (createRequire(import.meta.url)('../../package.json') as { version: string })
.version;
const NPX_REF = `gitnexus@${PKG_VERSION}`;
const MCP_PINNED_REF = `gitnexus@${PKG_VERSION}`;
const execFileMock = vi.fn((...args: any[]) => {
const callback = args.at(-1);
@ -82,7 +82,7 @@ describe('setupClaudeCode', () => {
expect(config.mcpServers.gitnexus).toEqual({
command: 'cmd',
args: ['/c', 'npx', '-y', NPX_REF, 'mcp'],
args: ['/c', 'npx', '-y', MCP_PINNED_REF, 'mcp'],
});
});
@ -97,7 +97,7 @@ describe('setupClaudeCode', () => {
expect(config.mcpServers.gitnexus).toEqual({
command: 'npx',
args: ['-y', NPX_REF, 'mcp'],
args: ['-y', MCP_PINNED_REF, 'mcp'],
});
});
@ -189,7 +189,7 @@ describe('setupClaudeCode', () => {
expect(config.mcpServers.gitnexus).toEqual({
command: 'npx',
args: ['-y', NPX_REF, 'mcp'],
args: ['-y', MCP_PINNED_REF, 'mcp'],
});
});
@ -267,19 +267,96 @@ describe('setupClaudeCode', () => {
});
});
it('copies hook-db-lock-probe.cjs and win-rm-list-json.ps1 to ~/.claude/hooks/gitnexus/', async () => {
it('copies shared hook helpers (incl. resolve-analyze-cmd.cjs) to ~/.claude/hooks/gitnexus/', async () => {
setPlatform('linux');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const destHooksDir = path.join(tempHome, '.claude', 'hooks', 'gitnexus');
await expect(fs.access(path.join(destHooksDir, 'hook-lock.cjs'))).resolves.toBeUndefined();
await expect(
fs.access(path.join(destHooksDir, 'hook-db-lock-probe.cjs')),
).resolves.toBeUndefined();
await expect(
fs.access(path.join(destHooksDir, 'win-rm-list-json.ps1')),
).resolves.toBeUndefined();
// The Claude adapter top-level require()s this; without it the installed
// hook would crash with MODULE_NOT_FOUND (the antigravity-side bug class).
await expect(
fs.access(path.join(destHooksDir, 'resolve-analyze-cmd.cjs')),
).resolves.toBeUndefined();
});
it('records errors and returns the failed REQUIRED helpers when copies fail', async () => {
const { copyHookHelpers } = await import('../../src/cli/setup.js');
const destDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-copy-helpers-'));
const result = { configured: [] as string[], skipped: [] as string[], errors: [] as string[] };
try {
// A non-existent source dir makes every helper copy fail.
const failedRequired = await copyHookHelpers(
path.join(destDir, 'nope'),
destDir,
'Claude Code hooks',
result,
);
expect(result.errors.length).toBe(4);
expect(result.errors.some((e) => e.includes('resolve-analyze-cmd.cjs'))).toBe(true);
expect(result.errors.every((e) => e.startsWith('Claude Code hooks:'))).toBe(true);
// Only the hard-required .cjs trio gates registration; win-rm is best-effort.
expect([...failedRequired].sort()).toEqual([
'hook-db-lock-probe.cjs',
'hook-lock.cjs',
'resolve-analyze-cmd.cjs',
]);
expect(failedRequired).not.toContain('win-rm-list-json.ps1');
} finally {
await fs.rm(destDir, { recursive: true, force: true });
}
});
it('treats win-rm-list-json.ps1 as best-effort (no required failure when only it is missing)', async () => {
const { copyHookHelpers } = await import('../../src/cli/setup.js');
const srcDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-helpers-src-'));
const destDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-helpers-dest-'));
const result = { configured: [] as string[], skipped: [] as string[], errors: [] as string[] };
try {
// Provide the three hard-required .cjs helpers; omit win-rm-list-json.ps1.
for (const h of ['hook-lock.cjs', 'hook-db-lock-probe.cjs', 'resolve-analyze-cmd.cjs']) {
await fs.writeFile(path.join(srcDir, h), '// stub\n', 'utf-8');
}
const failedRequired = await copyHookHelpers(srcDir, destDir, 'Claude Code hooks', result);
expect(failedRequired).toEqual([]);
// The best-effort helper still records a (non-gating) error.
expect(result.errors.some((e) => e.includes('win-rm-list-json.ps1'))).toBe(true);
} finally {
await fs.rm(srcDir, { recursive: true, force: true });
await fs.rm(destDir, { recursive: true, force: true });
}
});
it('does not register the Claude hook when a required helper fails to copy (fail closed)', async () => {
setPlatform('linux');
const realCopyFile = fs.copyFile.bind(fs);
vi.spyOn(fs, 'copyFile').mockImplementation(((src: any, dest: any, ...rest: any[]) => {
if (String(src).endsWith('resolve-analyze-cmd.cjs')) {
return Promise.reject(new Error('simulated copy failure'));
}
return realCopyFile(src, dest, ...rest);
}) as typeof fs.copyFile);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
// Registration must have been skipped — settings.json must not reference the hook.
const settingsPath = path.join(tempHome, '.claude', 'settings.json');
let registered = false;
try {
registered = (await fs.readFile(settingsPath, 'utf-8')).includes('gitnexus-hook.cjs');
} catch {
registered = false;
}
expect(registered).toBe(false);
});
it('falls back to npx on Windows when no .cmd/.bat wrapper is found', async () => {
@ -295,7 +372,7 @@ describe('setupClaudeCode', () => {
expect(config.mcpServers.gitnexus).toEqual({
command: 'cmd',
args: ['/c', 'npx', '-y', NPX_REF, 'mcp'],
args: ['/c', 'npx', '-y', MCP_PINNED_REF, 'mcp'],
});
});
@ -311,7 +388,70 @@ describe('setupClaudeCode', () => {
expect(config.mcpServers.gitnexus).toEqual({
command: 'cmd',
args: ['/c', 'npx', '-y', NPX_REF, 'mcp'],
args: ['/c', 'npx', '-y', MCP_PINNED_REF, 'mcp'],
});
});
// The hook `command` string is shell-evaluated by the editor. On POSIX the
// installed path lives under $HOME, which can legitimately contain spaces and
// (adversarially) shell metacharacters; the command must neutralize them.
it('single-quotes the POSIX hook command so the path cannot word-split or expand', async () => {
setPlatform('linux');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const settings = JSON.parse(
await fs.readFile(path.join(tempHome, '.claude', 'settings.json'), 'utf-8'),
);
const cmd: string = settings.hooks.PreToolUse[0].hooks[0].command;
// setup.ts forward-slash-normalizes the hook path (`.replace(/\\/g, '/')`)
// before quoting, so normalize the expected path the same way — otherwise
// path.join emits backslashes on the Windows runner and this mismatches.
const hookPath = path
.join(tempHome, '.claude', 'hooks', 'gitnexus', 'gitnexus-hook.cjs')
.replace(/\\/g, '/');
// Single-quoted, not double-quoted, and the path is the literal inside quotes.
expect(cmd).toBe(`node '${hookPath}'`);
expect(cmd.startsWith("node '")).toBe(true);
expect(cmd).not.toMatch(/^node "/);
});
});
describe('formatHookCommand (hook command escaping, #1945)', () => {
let mod: typeof import('../../src/cli/setup.js');
beforeEach(async () => {
mod = await import('../../src/cli/setup.js');
});
it('single-quotes an ordinary POSIX path', () => {
expect(mod.formatHookCommand('/home/dev/.claude/hooks/gitnexus/gitnexus-hook.cjs', false)).toBe(
"node '/home/dev/.claude/hooks/gitnexus/gitnexus-hook.cjs'",
);
});
it('neutralizes spaces in a POSIX path (no word-splitting)', () => {
expect(mod.formatHookCommand('/home/a b/.claude/gitnexus-hook.cjs', false)).toBe(
"node '/home/a b/.claude/gitnexus-hook.cjs'",
);
});
it('neutralizes shell metacharacters in a POSIX path ($, backtick, ;)', () => {
// Single-quoting means none of these can expand or run as a command.
const evil = '/home/u$(id)/`whoami`/a;b/.claude/gitnexus-hook.cjs';
expect(mod.formatHookCommand(evil, false)).toBe(`node '${evil}'`);
});
it("escapes a single quote in a POSIX path via the '\\'' idiom", () => {
expect(mod.formatHookCommand("/home/o'brien/.claude/gitnexus-hook.cjs", false)).toBe(
"node '/home/o'\\''brien/.claude/gitnexus-hook.cjs'",
);
});
it('keeps the double-quoted form on Windows (metacharacters are illegal in filenames)', () => {
expect(mod.formatHookCommand('C:/Users/dev/.claude/gitnexus-hook.cjs', true)).toBe(
'node "C:/Users/dev/.claude/gitnexus-hook.cjs"',
);
});
});

View file

@ -0,0 +1,137 @@
import { describe, it, expect } from 'vitest';
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import path from 'node:path';
// Steering policy (#1939, #1945): the committed skill files route gitnexus
// commands through the project-local runner `gitnexus analyze` drops next to the
// index (`node .gitnexus/run.cjs <command>`). That one CLI-neutral command
// resolves the available runner (global `gitnexus` → `pnpm dlx` → `npx`) at call
// time, so the docs make no package-manager assumption. The runner only exists
// after the first analyze, so the cli skill documents a bootstrap path (and the
// npm-11 `node.target is null` npx install-crash escape hatch). When the pnpm
// fallback is shown it must use the pre-`dlx` `--allow-build` position (honored
// since pnpm 10.2); the post-`dlx` position is rejected as a package spec on
// pnpm 10.2–10.13.x.
//
// Pure file reads resolved via path.resolve — deterministic, no host-PATH or
// glob-CWD dependence, so this needs no cross-platform-tests.ts registration.
const GITNEXUS_ROOT = path.resolve(__dirname, '..', '..'); // gitnexus/test/unit -> gitnexus/
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..'); // -> monorepo root
function collectSkillFiles(): string[] {
const files: string[] = [];
// Bundled ship source: flat *.md files installSkills() copies to new users.
const bundled = path.join(GITNEXUS_ROOT, 'skills');
if (existsSync(bundled)) {
for (const f of readdirSync(bundled)) {
if (f.endsWith('.md')) files.push(path.join(bundled, f));
}
}
// Per-skill <name>/SKILL.md copies across the other distribution locations.
const skillRoots = [
path.join(REPO_ROOT, '.claude', 'skills', 'gitnexus'),
path.join(REPO_ROOT, 'gitnexus-claude-plugin', 'skills'),
path.join(REPO_ROOT, 'gitnexus-cursor-integration', 'skills'),
];
for (const root of skillRoots) {
if (!existsSync(root)) continue;
for (const dir of readdirSync(root)) {
const skillMd = path.join(root, dir, 'SKILL.md');
if (existsSync(skillMd)) files.push(skillMd);
}
}
return files;
}
function cliSkillFiles(files: string[]): string[] {
return files.filter(
(f) =>
/gitnexus-cli/.test(path.basename(path.dirname(f))) || path.basename(f) === 'gitnexus-cli.md',
);
}
describe('skill-file steering (#1939, #1945)', () => {
const files = collectSkillFiles();
it('collects skill files from all four committed locations (guard is not vacuous)', () => {
const rels = files.map((f) => path.relative(REPO_ROOT, f));
expect(rels.some((r) => r.startsWith(`gitnexus${path.sep}skills${path.sep}`))).toBe(true);
expect(
rels.some((r) => r.startsWith(path.join('.claude', 'skills', 'gitnexus') + path.sep)),
).toBe(true);
expect(
rels.some((r) => r.startsWith(path.join('gitnexus-claude-plugin', 'skills') + path.sep)),
).toBe(true);
expect(
rels.some((r) => r.startsWith(path.join('gitnexus-cursor-integration', 'skills') + path.sep)),
).toBe(true);
});
it('routes EVERY cli skill subcommand through the project-local runner (#1945)', () => {
// The cli skill demonstrates every subcommand. Each must invoke the
// CLI-neutral runner `gitnexus analyze` drops next to the index — not a
// hardcoded package manager — so the docs make no pnpm/npx assumption.
// Checking each subcommand (not just `analyze`) guards against a regression
// where status/clean/wiki/list silently revert to `npx gitnexus <sub>`.
const cli = cliSkillFiles(files);
expect(cli.length).toBeGreaterThan(0); // guard is not vacuous
const SUBCOMMANDS = ['analyze', 'status', 'clean', 'wiki', 'list'];
const offenders: string[] = [];
for (const f of cli) {
const text = readFileSync(f, 'utf-8');
for (const sub of SUBCOMMANDS) {
if (!new RegExp(`node\\s+\\.gitnexus/run\\.cjs\\s+${sub}\\b`).test(text)) {
offenders.push(`${path.relative(REPO_ROOT, f)}:${sub}`);
}
}
}
expect(offenders).toEqual([]);
});
it('documents the not-analyzed-yet / npm-11 bootstrap fallback in the cli skill (#1939)', () => {
// The runner only exists after the first analyze, so the cli skill must
// document the bootstrap path (and the npm-11 npx install crash escape
// hatch): the issue reference plus at least one fallback mechanism.
const cli = cliSkillFiles(files);
const offenders = cli.filter((f) => {
const text = readFileSync(f, 'utf-8');
const refsIssue = /1939/.test(text);
const hasFallback =
/install -g gitnexus/.test(text) || /--allow-build.*dlx gitnexus/.test(text);
return !(refsIssue && hasFallback);
});
expect(offenders.map((f) => path.relative(REPO_ROOT, f))).toEqual([]);
// Positive vacuity guard: at least one cli skill must still carry the pnpm
// pre-`dlx` fallback form, so the npm-11 pnpm path can't silently vanish
// from every skill while the OR above is satisfied by `install -g` alone.
const withPnpmFallback = cli.filter((f) =>
/--allow-build.*dlx gitnexus/.test(readFileSync(f, 'utf-8')),
);
expect(withPnpmFallback.length).toBeGreaterThan(0);
});
it('routes every stale-index reanalyze hint through the runner, not a raw package manager', () => {
// Skills that tell the agent to reanalyze a stale index must point at the
// runner so the package-manager choice is resolved at call time.
const offenders = files.filter((f) => {
const text = readFileSync(f, 'utf-8');
if (!/[Ss]tale/.test(text)) return false; // only skills with a reanalyze hint
return !/node\s+\.gitnexus\/run\.cjs\s+analyze/.test(text);
});
expect(offenders.map((f) => path.relative(REPO_ROOT, f))).toEqual([]);
});
it('any pnpm fallback uses the pre-`dlx` --allow-build form, never the broken post-`dlx` position', () => {
// `pnpm dlx --allow-build=…` (flags after `dlx`) is parsed as a package spec
// and rejected on pnpm 10.2–10.13.x; the flags must precede `dlx` (#1939).
const postDlxOffenders = files.filter((f) =>
/pnpm dlx --allow-build/.test(readFileSync(f, 'utf-8')),
);
expect(postDlxOffenders.map((f) => path.relative(REPO_ROOT, f))).toEqual([]);
});
});

View file

@ -14,7 +14,7 @@ export function runHook(
encoding: 'utf-8',
timeout: 10000,
cwd,
env: options.env,
env: options.env ? { ...process.env, ...options.env } : process.env,
stdio: ['pipe', 'pipe', 'pipe'],
});
return {