gitnexus 1.6.6-rc.113 → 1.6.6-rc.115

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,6 +24,14 @@ npx gitnexus analyze
24
24
 
25
25
  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.
26
26
 
27
+ > **On npm 11.x?** `npx` can crash during install (`Cannot destructure property 'package' of 'node.target'`). Use the pnpm form instead:
28
+ >
29
+ > ```bash
30
+ > pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze
31
+ > ```
32
+ >
33
+ > 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).
34
+
27
35
  To configure MCP for your editor, run `npx gitnexus setup` once — or set it up manually below.
28
36
 
29
37
  `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.
273
281
 
274
282
  ### `Cannot destructure property 'package' of 'node.target' as it is null`
275
283
 
276
- This crash was caused by a dependency URL format that is incompatible with
277
- certain npm/arborist versions ([npm/cli#8126](https://github.com/npm/cli/issues/8126)).
278
- It is fixed in **gitnexus v1.6.2+**. Upgrade to the latest version:
284
+ 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:
279
285
 
280
286
  ```bash
281
- npx gitnexus@latest analyze # always uses the newest release
282
- # or
283
- npm install -g gitnexus@latest # upgrade a global install
287
+ pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze # auto-selected when pnpm + npm 11+
288
+ npm install -g gitnexus@latest # global install avoids per-run npx reify
289
+ gitnexus analyze # if already installed globally
290
+ ```
291
+
292
+ 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`.
293
+
294
+ If you must stay on npm 11.x without pnpm, downgrade npm toolchain-wide (last resort):
295
+
296
+ ```bash
297
+ npm install -g npm@10.9.0
284
298
  ```
285
299
 
286
- If you still hit npm install issues after upgrading, these generic workarounds
287
- may help:
300
+ 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:
288
301
 
289
302
  ```bash
290
- npm install -g npm@latest # update npm itself
291
- npm cache clean --force # clear a possibly corrupt cache
303
+ npm cache clean --force
304
+ npx gitnexus@latest analyze
292
305
  ```
293
306
 
294
307
  ### `ERR_DLOPEN_FAILED` / `lbugjs.node` missing (pnpm dlx, pnpx)
@@ -19,10 +19,11 @@ export interface AIContextOptions {
19
19
  noStats?: boolean;
20
20
  skipSkills?: boolean;
21
21
  }
22
+ export declare function generateGitNexusContent(projectName: string, stats: RepoStats, generatedSkills?: GeneratedSkillInfo[], groupNames?: string[], noStats?: boolean, skipSkills?: boolean, runnerPath?: string): string;
22
23
  /**
23
24
  * Generate AI context files after indexing
24
25
  */
25
- export declare function generateAIContextFiles(repoPath: string, _storagePath: string, projectName: string, stats: RepoStats, generatedSkills?: GeneratedSkillInfo[], options?: AIContextOptions): Promise<{
26
+ export declare function generateAIContextFiles(repoPath: string, storagePath: string, projectName: string, stats: RepoStats, generatedSkills?: GeneratedSkillInfo[], options?: AIContextOptions): Promise<{
26
27
  files: string[];
27
28
  }>;
28
29
  export {};
@@ -68,7 +68,11 @@ async function findGroupsContainingRegistryName(registryName) {
68
68
  }
69
69
  return hits;
70
70
  }
71
- function generateGitNexusContent(projectName, stats, generatedSkills, groupNames, noStats, skipSkills) {
71
+ export function generateGitNexusContent(projectName, stats, generatedSkills, groupNames, noStats, skipSkills,
72
+ // Project-relative path to the runner `gitnexus analyze` drops next to the
73
+ // index (#1945). Referenced by docs so a single CLI-neutral command resolves
74
+ // the available runner (global `gitnexus` → `pnpm dlx` → `npx`) at call time.
75
+ runnerPath = '.gitnexus/run.cjs') {
72
76
  const generatedRows = generatedSkills && generatedSkills.length > 0
73
77
  ? generatedSkills
74
78
  .map((s) => `| Work in the ${s.label} area (${s.symbolCount} symbols) | \`.claude/skills/generated/${s.name}/SKILL.md\` |`)
@@ -93,12 +97,20 @@ function generateGitNexusContent(projectName, stats, generatedSkills, groupNames
93
97
  |------|---------------------|
94
98
  ${tableBody}`
95
99
  : '';
100
+ // Docs reference the project-local runner `gitnexus analyze` writes (#1945):
101
+ // a single, CLI-neutral, machine-independent command (no per-machine churn,
102
+ // #1706) that auto-selects the available runner at call time. Kept terse to
103
+ // stay under the CLAUDE.md block token budget (#856); the cli skill carries the
104
+ // full bootstrap + npm-11 fallback (`node.target is null` npx install crash).
105
+ const runner = `node ${runnerPath}`;
106
+ const bootstrapNote = `No \`${runnerPath}\` yet? \`npx gitnexus analyze\` ` +
107
+ '(npm 11 crash → `npm i -g gitnexus`; #1939).';
96
108
  return `${GITNEXUS_START_MARKER}
97
109
  # GitNexus — Code Intelligence
98
110
 
99
111
  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.
100
112
 
101
- > If any GitNexus tool warns the index is stale, run \`npx gitnexus analyze\` in terminal first.
113
+ > Index stale? Run \`${runner} analyze\` from the project root it auto-selects an available runner. ${bootstrapNote}
102
114
 
103
115
  ## Always Do
104
116
 
@@ -127,7 +139,7 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s
127
139
  ${groupNames && groupNames.length > 0
128
140
  ? `## Cross-Repo Groups
129
141
 
130
- 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>\`.
142
+ 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).
131
143
 
132
144
  `
133
145
  : ''}${skillsTable
@@ -302,9 +314,24 @@ Use GitNexus tools to accomplish this task.
302
314
  /**
303
315
  * Generate AI context files after indexing
304
316
  */
305
- export async function generateAIContextFiles(repoPath, _storagePath, projectName, stats, generatedSkills, options) {
317
+ export async function generateAIContextFiles(repoPath, storagePath, projectName, stats, generatedSkills, options) {
306
318
  const groupNames = await findGroupsContainingRegistryName(projectName);
307
- const content = generateGitNexusContent(projectName, stats, generatedSkills, groupNames, options?.noStats, options?.skipSkills);
319
+ // Drop a project-local runner next to the index (#1945) so the generated docs
320
+ // can reference one CLI-neutral command that resolves the available runner at
321
+ // call time. It is a copy of the canonical self-contained resolver, which the
322
+ // CLI and hooks already share; failure to copy is non-fatal (docs carry a
323
+ // bootstrap fallback). `runnerPath` is project-relative with POSIX separators
324
+ // so the emitted command is identical across platforms.
325
+ const runnerPath = path.relative(repoPath, path.join(storagePath, 'run.cjs')).replace(/\\/g, '/');
326
+ try {
327
+ const runnerSrc = path.join(__dirname, '..', '..', 'hooks', 'claude', 'resolve-analyze-cmd.cjs');
328
+ await fs.mkdir(storagePath, { recursive: true });
329
+ await fs.copyFile(runnerSrc, path.join(storagePath, 'run.cjs'));
330
+ }
331
+ catch (err) {
332
+ logger.warn(`Could not write GitNexus runner to ${runnerPath}: ${String(err)}`);
333
+ }
334
+ const content = generateGitNexusContent(projectName, stats, generatedSkills, groupNames, options?.noStats, options?.skipSkills, runnerPath);
308
335
  const createdFiles = [];
309
336
  if (!options?.skipAgentsMd) {
310
337
  // Create AGENTS.md (standard for Cursor, Windsurf, OpenCode, Cline, etc.)
@@ -23,6 +23,7 @@ import fs from 'fs/promises';
23
23
  import { cliError } from './cli-message.js';
24
24
  import { formatElapsed } from './format-elapsed.js';
25
25
  import { isHfDownloadFailure } from '../core/embeddings/hf-env.js';
26
+ import { warnIfNpm11NpxRisk } from './resolve-invocation.js';
26
27
  // Capture stderr.write at module load BEFORE anything (LadybugDB native
27
28
  // init, progress bar, console redirection) can monkey-patch it. The
28
29
  // fatal handlers below MUST reach the user even when the analyze path
@@ -433,6 +434,10 @@ export const analyzeCommand = async (inputPath, options) => {
433
434
  // async error that escapes the try/catch below (#1169) surfaces with
434
435
  // a stack trace and a non-zero exit code instead of a silent exit 0.
435
436
  installFatalHandlers();
437
+ // npm-11 npx-crash nudge (#1939). Runs here, after the heap re-exec guard,
438
+ // so it fires once in the working process and never on the lazy-startup path
439
+ // of other commands (e.g. `gitnexus mcp`).
440
+ warnIfNpm11NpxRisk();
436
441
  // Snapshot the GITNEXUS_* env vars that the impl writes for downstream
437
442
  // consumption, so they don't leak across `analyzeCommand` invocations in
438
443
  // programmatic callers (tests, long-running hosts). `process.exit(0)` on
@@ -0,0 +1,21 @@
1
+ /**
2
+ * npm 11.x npx-install-crash nudge for the `analyze` command (#1939).
3
+ *
4
+ * The gitnexus/pnpm/npx selection itself lives in the canonical hook helper
5
+ * (hooks/claude/resolve-analyze-cmd.cjs) — self-contained CJS because the copied
6
+ * hook runtime cannot import from the package. We reuse it here via createRequire
7
+ * instead of re-implementing it, so there is one source of truth for the
8
+ * invocation decision. This module adds only the npm-version probe and the
9
+ * warning, which are CLI-only. The relative path resolves identically from
10
+ * src/cli/ (tsx, vitest) and dist/cli/ (shipped), since both sit one level under
11
+ * the package root and `hooks/` is published.
12
+ */
13
+ declare const NPX_REF: string;
14
+ export { NPX_REF };
15
+ export declare function getNpmMajorVersion(): number | null;
16
+ /**
17
+ * One-line stderr nudge when an npm 11+ user is on the npx install path (#1939).
18
+ * Skipped when a global `gitnexus` or `pnpm` is already preferred, so it never
19
+ * nags users who are not exposed to the npx/arborist crash.
20
+ */
21
+ export declare function warnIfNpm11NpxRisk(): void;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * npm 11.x npx-install-crash nudge for the `analyze` command (#1939).
3
+ *
4
+ * The gitnexus/pnpm/npx selection itself lives in the canonical hook helper
5
+ * (hooks/claude/resolve-analyze-cmd.cjs) — self-contained CJS because the copied
6
+ * hook runtime cannot import from the package. We reuse it here via createRequire
7
+ * instead of re-implementing it, so there is one source of truth for the
8
+ * invocation decision. This module adds only the npm-version probe and the
9
+ * warning, which are CLI-only. The relative path resolves identically from
10
+ * src/cli/ (tsx, vitest) and dist/cli/ (shipped), since both sit one level under
11
+ * the package root and `hooks/` is published.
12
+ */
13
+ import { execFileSync } from 'node:child_process';
14
+ import { createRequire } from 'node:module';
15
+ const { resolveInvocationMode, formatDocumentationDlxCommand, NPX_REF } = createRequire(import.meta.url)('../../hooks/claude/resolve-analyze-cmd.cjs');
16
+ // Fail loud at module load if the canonical cjs export shape drifts (e.g. a
17
+ // renamed export), rather than as a late TypeError inside warnIfNpm11NpxRisk.
18
+ if (typeof resolveInvocationMode !== 'function' ||
19
+ typeof formatDocumentationDlxCommand !== 'function' ||
20
+ typeof NPX_REF !== 'string') {
21
+ throw new Error('resolve-analyze-cmd.cjs must export resolveInvocationMode (function), formatDocumentationDlxCommand (function), and NPX_REF (string)');
22
+ }
23
+ export { NPX_REF };
24
+ // Re-implemented here (rather than reusing the cjs export) so vitest's
25
+ // `vi.mock('node:child_process')` intercepts it — the cjs uses bare
26
+ // `require('child_process')`, which the mock cannot reach. Timeout matches the
27
+ // cjs PROBE_TIMEOUT_MS (1s) so this CLI probe shares the same hook-budget cap;
28
+ // `npm --version` is a sub-second local call.
29
+ export function getNpmMajorVersion() {
30
+ try {
31
+ const output = execFileSync('npm', ['--version'], {
32
+ encoding: 'utf-8',
33
+ timeout: 1000,
34
+ stdio: ['ignore', 'pipe', 'ignore'],
35
+ windowsHide: true,
36
+ // Windows `npm` is a `.cmd` shim; without a shell execFileSync ENOENTs
37
+ // (CVE-2024-27980) and the npm-11 npx-crash warning below would never
38
+ // fire on Windows. Mirrors probeVersion in resolve-analyze-cmd.cjs.
39
+ shell: process.platform === 'win32',
40
+ });
41
+ // Read the first version-shaped line so a Corepack/update banner on stdout
42
+ // doesn't defeat the parse (mirrors the cjs probeVersion hardening).
43
+ const major = output
44
+ .split('\n')
45
+ .map((l) => l.trim())
46
+ .find((l) => /^v?\d+\./.test(l))
47
+ ?.match(/^v?(\d+)\./);
48
+ return major ? Number(major[1]) : null;
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ /**
55
+ * One-line stderr nudge when an npm 11+ user is on the npx install path (#1939).
56
+ * Skipped when a global `gitnexus` or `pnpm` is already preferred, so it never
57
+ * nags users who are not exposed to the npx/arborist crash.
58
+ */
59
+ export function warnIfNpm11NpxRisk() {
60
+ if (resolveInvocationMode() !== 'npx')
61
+ return;
62
+ const major = getNpmMajorVersion();
63
+ if (major === null || major < 11)
64
+ return;
65
+ process.stderr.write(`Warning: npm ${major}.x can crash while installing gitnexus via npx ` +
66
+ `(npm/arborist "node.target is null"). Prefer: ${formatDocumentationDlxCommand('analyze')} ` +
67
+ `or npm install -g ${NPX_REF}. See https://github.com/abhigyanpatwari/GitNexus/issues/1939\n`);
68
+ }
@@ -5,4 +5,37 @@
5
5
  * Detects installed AI editors and writes the appropriate MCP config
6
6
  * so the GitNexus MCP server is available in all projects.
7
7
  */
8
+ /**
9
+ * Build the `command` string written into an editor's hook settings, which the
10
+ * editor shell-evaluates. `hookPath` is already forward-slash-normalized.
11
+ *
12
+ * On POSIX, single-quote the path: a single-quoted shell string expands nothing,
13
+ * so spaces and metacharacters ($, backtick, ;, |, &, newline, parens) in the
14
+ * install path cannot run as commands. The only character needing escaping
15
+ * inside single quotes is the single quote, via the standard `'\''` idiom
16
+ * (close, literal-quote, reopen). The previous double-quoted `node "..."` form
17
+ * left $/backtick live — a code-execution risk for an adversarial $HOME.
18
+ *
19
+ * On Windows, filenames cannot contain these POSIX metacharacters and the path
20
+ * is forward-slashed, so keep the double-quoted form with backslash-then-quote
21
+ * escaping (CodeQL js/incomplete-sanitization safe ordering).
22
+ */
23
+ export declare function formatHookCommand(hookPath: string, isWindows?: boolean): string;
24
+ interface SetupResult {
25
+ configured: string[];
26
+ skipped: string[];
27
+ errors: string[];
28
+ }
29
+ /**
30
+ * Copy the shared hook helpers from `srcDir` into `destDir`. The adapters
31
+ * top-level `require()` the `.cjs` helpers, so a missing required helper makes
32
+ * the installed hook crash with MODULE_NOT_FOUND. A failed copy is recorded as a
33
+ * setup error, and the names of any failed REQUIRED helpers are returned so the
34
+ * caller can fail closed (skip hook registration) instead of registering a hook
35
+ * that crashes at runtime. `win-rm-list-json.ps1` is best-effort — its absence is
36
+ * recorded but does not gate registration. Both the Claude and Antigravity
37
+ * install paths copy this same list from hooks/claude/ (the canonical source).
38
+ */
39
+ export declare function copyHookHelpers(srcDir: string, destDir: string, label: string, result: SetupResult): Promise<string[]>;
8
40
  export declare const setupCommand: () => Promise<void>;
41
+ export {};
package/dist/cli/setup.js CHANGED
@@ -28,7 +28,38 @@ const _pkg = _require('../../package.json');
28
28
  if (typeof _pkg.version !== 'string' || !_pkg.version) {
29
29
  throw new Error('gitnexus/package.json#version is missing or not a string — cannot generate MCP fallback config.');
30
30
  }
31
- const NPX_REF = `gitnexus@${_pkg.version}`;
31
+ // Version-pinned ref for the persisted MCP entry — deliberately distinct from
32
+ // the cjs's exported `gitnexus@latest` hint ref (resolve-analyze-cmd.cjs); the
33
+ // two are not unified (see the comment above and that file's MCP_PINNED_REF).
34
+ const MCP_PINNED_REF = `gitnexus@${_pkg.version}`;
35
+ /**
36
+ * Build the `command` string written into an editor's hook settings, which the
37
+ * editor shell-evaluates. `hookPath` is already forward-slash-normalized.
38
+ *
39
+ * On POSIX, single-quote the path: a single-quoted shell string expands nothing,
40
+ * so spaces and metacharacters ($, backtick, ;, |, &, newline, parens) in the
41
+ * install path cannot run as commands. The only character needing escaping
42
+ * inside single quotes is the single quote, via the standard `'\''` idiom
43
+ * (close, literal-quote, reopen). The previous double-quoted `node "..."` form
44
+ * left $/backtick live — a code-execution risk for an adversarial $HOME.
45
+ *
46
+ * On Windows, filenames cannot contain these POSIX metacharacters and the path
47
+ * is forward-slashed, so keep the double-quoted form with backslash-then-quote
48
+ * escaping (CodeQL js/incomplete-sanitization safe ordering).
49
+ */
50
+ export function formatHookCommand(hookPath, isWindows = process.platform === 'win32') {
51
+ if (isWindows) {
52
+ const escaped = hookPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
53
+ return `node "${escaped}"`;
54
+ }
55
+ return `node '${hookPath.replace(/'/g, "'\\''")}'`;
56
+ }
57
+ // The exact source line each hook adapter ships, rewritten at install time to
58
+ // point cliPath at the installed CLI. Kept as a named constant so the install
59
+ // patch and its drift guard reference one string — if the adapter source ever
60
+ // changes this literal, the guard records an actionable error instead of
61
+ // silently shipping a hook with an unresolved relative cliPath.
62
+ const CLI_PATH_SOURCE_LITERAL = "let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');";
32
63
  /**
33
64
  * Resolve the absolute path to the `gitnexus` binary if it's installed
34
65
  * globally (or via npm -g / yarn global). Returns null when not found.
@@ -83,12 +114,12 @@ function getMcpEntry() {
83
114
  if (process.platform === 'win32') {
84
115
  return {
85
116
  command: 'cmd',
86
- args: ['/c', 'npx', '-y', NPX_REF, 'mcp'],
117
+ args: ['/c', 'npx', '-y', MCP_PINNED_REF, 'mcp'],
87
118
  };
88
119
  }
89
120
  return {
90
121
  command: 'npx',
91
- args: ['-y', NPX_REF, 'mcp'],
122
+ args: ['-y', MCP_PINNED_REF, 'mcp'],
92
123
  };
93
124
  }
94
125
  /**
@@ -101,9 +132,9 @@ function getOpenCodeMcpEntry() {
101
132
  return { type: 'local', command: [bin, 'mcp'] };
102
133
  }
103
134
  if (process.platform === 'win32') {
104
- return { type: 'local', command: ['cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'] };
135
+ return { type: 'local', command: ['cmd', '/c', 'npx', '-y', MCP_PINNED_REF, 'mcp'] };
105
136
  }
106
- return { type: 'local', command: ['npx', '-y', NPX_REF, 'mcp'] };
137
+ return { type: 'local', command: ['npx', '-y', MCP_PINNED_REF, 'mcp'] };
107
138
  }
108
139
  /**
109
140
  * Detect indentation style from file content.
@@ -281,6 +312,42 @@ async function mergeHooksJsonc(filePath, entries) {
281
312
  await fs.writeFile(filePath, current, 'utf-8');
282
313
  return true;
283
314
  }
315
+ const HOOK_HELPERS = [
316
+ 'hook-lock.cjs',
317
+ 'hook-db-lock-probe.cjs',
318
+ 'win-rm-list-json.ps1',
319
+ 'resolve-analyze-cmd.cjs',
320
+ ];
321
+ // win-rm-list-json.ps1 is best-effort: it is read (not require()'d) by
322
+ // hook-db-lock-probe.cjs only on Windows, and that probe fails open when the
323
+ // script is absent. Every other helper is top-level require()'d by the adapters,
324
+ // so its absence crashes the installed hook — those are the ones a failed copy
325
+ // must gate hook registration on (see copyHookHelpers' return value).
326
+ const BEST_EFFORT_HOOK_HELPERS = new Set(['win-rm-list-json.ps1']);
327
+ /**
328
+ * Copy the shared hook helpers from `srcDir` into `destDir`. The adapters
329
+ * top-level `require()` the `.cjs` helpers, so a missing required helper makes
330
+ * the installed hook crash with MODULE_NOT_FOUND. A failed copy is recorded as a
331
+ * setup error, and the names of any failed REQUIRED helpers are returned so the
332
+ * caller can fail closed (skip hook registration) instead of registering a hook
333
+ * that crashes at runtime. `win-rm-list-json.ps1` is best-effort — its absence is
334
+ * recorded but does not gate registration. Both the Claude and Antigravity
335
+ * install paths copy this same list from hooks/claude/ (the canonical source).
336
+ */
337
+ export async function copyHookHelpers(srcDir, destDir, label, result) {
338
+ const failedRequired = [];
339
+ for (const helper of HOOK_HELPERS) {
340
+ try {
341
+ await fs.copyFile(path.join(srcDir, helper), path.join(destDir, helper));
342
+ }
343
+ catch {
344
+ result.errors.push(`${label}: failed to copy ${helper} — hook may crash at runtime`);
345
+ if (!BEST_EFFORT_HOOK_HELPERS.has(helper))
346
+ failedRequired.push(helper);
347
+ }
348
+ }
349
+ return failedRequired;
350
+ }
284
351
  /**
285
352
  * Install GitNexus hooks to ~/.claude/settings.json for Claude Code.
286
353
  * Merges hook config without overwriting existing hooks, preserving
@@ -304,37 +371,32 @@ async function installClaudeCodeHooks(result) {
304
371
  const resolvedCli = path.join(__dirname, '..', 'cli', 'index.js');
305
372
  const normalizedCli = path.resolve(resolvedCli).replace(/\\/g, '/');
306
373
  const jsonCli = JSON.stringify(normalizedCli);
307
- content = content.replace("let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');", `let cliPath = ${jsonCli};`);
374
+ if (!content.includes(CLI_PATH_SOURCE_LITERAL)) {
375
+ 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.');
376
+ }
377
+ content = content.replace(CLI_PATH_SOURCE_LITERAL, `let cliPath = ${jsonCli};`);
308
378
  await fs.writeFile(dest, content, 'utf-8');
309
379
  }
310
380
  catch {
311
381
  // Script not found in source — skip
312
382
  }
383
+ // Fail closed: registering the hook without its adapter would crash on every
384
+ // tool invocation. Mirrors the Antigravity adapter guard below (this path
385
+ // previously registered regardless of whether the adapter wrote).
313
386
  try {
314
- await fs.copyFile(path.join(pluginHooksPath, 'hook-lock.cjs'), path.join(destHooksDir, 'hook-lock.cjs'));
387
+ await fs.access(dest);
315
388
  }
316
389
  catch {
317
- // Helper not found in sourceskip
318
- }
319
- try {
320
- await fs.copyFile(path.join(pluginHooksPath, 'hook-db-lock-probe.cjs'), path.join(destHooksDir, 'hook-db-lock-probe.cjs'));
321
- }
322
- catch {
323
- // Helper not found in source — skip
324
- }
325
- try {
326
- await fs.copyFile(path.join(pluginHooksPath, 'win-rm-list-json.ps1'), path.join(destHooksDir, 'win-rm-list-json.ps1'));
390
+ result.errors.push('Claude Code hooks: adapter script was not installed skipping hook registration');
391
+ return;
327
392
  }
328
- catch {
329
- // Helper not found in source — skip
393
+ const failedRequired = await copyHookHelpers(pluginHooksPath, destHooksDir, 'Claude Code hooks', result);
394
+ if (failedRequired.length > 0) {
395
+ result.errors.push(`Claude Code hooks: required helper(s) ${failedRequired.join(', ')} failed to copy — skipping hook registration`);
396
+ return;
330
397
  }
331
398
  const hookPath = path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/');
332
- // Escape backslashes FIRST, then quotes (CodeQL js/incomplete-sanitization).
333
- // The previous shape `replace(/"/g, '\\"')` alone would let `path\with"quote`
334
- // become `path\with\"quote`, where the trailing `\` before `"` could
335
- // unescape the quote inside the surrounding double-quoted shell context.
336
- const escapedHookPath = hookPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
337
- const hookCmd = `node "${escapedHookPath}"`;
399
+ const hookCmd = formatHookCommand(hookPath);
338
400
  // Check which hook events need entries (idempotent: skip if already registered)
339
401
  const parsed = await (async () => {
340
402
  try {
@@ -483,7 +545,10 @@ async function installAntigravityHooks(result) {
483
545
  const resolvedCli = path.join(__dirname, '..', 'cli', 'index.js');
484
546
  const normalizedCli = path.resolve(resolvedCli).replace(/\\/g, '/');
485
547
  const jsonCli = JSON.stringify(normalizedCli);
486
- content = content.replace("let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');", `let cliPath = ${jsonCli};`);
548
+ if (!content.includes(CLI_PATH_SOURCE_LITERAL)) {
549
+ 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.');
550
+ }
551
+ content = content.replace(CLI_PATH_SOURCE_LITERAL, `let cliPath = ${jsonCli};`);
487
552
  await fs.writeFile(adapterDest, content, 'utf-8');
488
553
  }
489
554
  catch {
@@ -503,17 +568,13 @@ async function installAntigravityHooks(result) {
503
568
  // required by hook-db-lock-probe.cjs on Windows — without it, the MCP
504
569
  // server ownership probe silently fails open and the hook may contend
505
570
  // with the MCP server on the LadybugDB.
506
- for (const helper of ['hook-lock.cjs', 'hook-db-lock-probe.cjs', 'win-rm-list-json.ps1']) {
507
- try {
508
- await fs.copyFile(path.join(pluginClaudeDir, helper), path.join(destHooksDir, helper));
509
- }
510
- catch {
511
- result.errors.push(`Antigravity hooks: failed to copy ${helper} — hook may crash at runtime`);
512
- }
571
+ const failedRequired = await copyHookHelpers(pluginClaudeDir, destHooksDir, 'Antigravity hooks', result);
572
+ if (failedRequired.length > 0) {
573
+ result.errors.push(`Antigravity hooks: required helper(s) ${failedRequired.join(', ')} failed to copy — skipping hook registration`);
574
+ return;
513
575
  }
514
576
  const hookPath = path.join(destHooksDir, 'gitnexus-antigravity-hook.cjs').replace(/\\/g, '/');
515
- const escapedHookPath = hookPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
516
- const hookCmd = `node "${escapedHookPath}"`;
577
+ const hookCmd = formatHookCommand(hookPath);
517
578
  const parsed = await (async () => {
518
579
  try {
519
580
  const r = await fs.readFile(settingsPath, 'utf-8');
@@ -11,11 +11,35 @@ const PYTHON_SCOPE_QUERY = `
11
11
  (module) @scope.module
12
12
  (class_definition) @scope.class
13
13
  (function_definition) @scope.function
14
+ (lambda) @scope.function
14
15
 
15
16
  ;; Declarations
16
17
  (class_definition
17
18
  name: (identifier) @declaration.name) @declaration.class
18
19
 
20
+ ;; Heritage — bare identifier
21
+ ;; NOTE: captures.ts on main already synthesizes @reference.inherits for
22
+ ;; qualified bases via #1951/#1956. These @heritage.* patterns are redundant
23
+ ;; with that synthesis but kept as documentation and a safety net for the
24
+ ;; generic heritage extractor path. They produce topicOf edges that the
25
+ ;; resolution pipeline ignores when the synthesis path wins.
26
+ (class_definition
27
+ name: (identifier) @heritage.class
28
+ superclasses: (argument_list
29
+ (identifier) @heritage.extends)) @heritage
30
+
31
+ ;; Heritage — qualified base (module.Class)
32
+ (class_definition
33
+ name: (identifier) @heritage.class
34
+ superclasses: (argument_list
35
+ (attribute) @heritage.extends)) @heritage
36
+
37
+ ;; Heritage — subscripted/generic base (Generic[T])
38
+ (class_definition
39
+ name: (identifier) @heritage.class
40
+ superclasses: (argument_list
41
+ (subscript) @heritage.extends)) @heritage
42
+
19
43
  (function_definition
20
44
  name: (identifier) @declaration.name) @declaration.function
21
45
 
@@ -232,6 +256,22 @@ const PYTHON_SCOPE_QUERY = `
232
256
  name: (identifier) @type-binding.name
233
257
  return_type: (type) @type-binding.type) @type-binding.return
234
258
 
259
+ ;; Decorators — simple @decorator
260
+ (decorator
261
+ (identifier) @reference.name) @reference.call.free
262
+
263
+ ;; Decorators — @obj.decorator (single attribute, identifier receiver)
264
+ (decorator
265
+ (attribute
266
+ object: (identifier) @reference.receiver
267
+ attribute: (identifier) @reference.name)) @reference.call.member
268
+
269
+ ;; Decorators — @a.b.decorator (nested attributes)
270
+ (decorator
271
+ (attribute
272
+ object: (attribute) @reference.receiver
273
+ attribute: (identifier) @reference.name)) @reference.call.member
274
+
235
275
  ;; References — calls
236
276
  (call
237
277
  function: (identifier) @reference.name) @reference.call.free
@@ -25,6 +25,7 @@ const path = require('path');
25
25
  const { spawnSync } = require('child_process');
26
26
  const { acquireHookSlot } = require('./hook-lock.cjs');
27
27
  const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs');
28
+ const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs');
28
29
 
29
30
  function readInput() {
30
31
  try {
@@ -315,7 +316,7 @@ function buildStaleIndexHint(gitNexusDir, cwd) {
315
316
 
316
317
  if (currentHead === lastCommit) return '';
317
318
 
318
- const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`;
319
+ const analyzeCmd = formatAnalyzeCommand({ embeddings: hadEmbeddings });
319
320
  return (
320
321
  `[GitNexus] index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` +
321
322
  `Run \`${analyzeCmd}\` to refresh the knowledge graph.`
@@ -16,6 +16,7 @@ const path = require('path');
16
16
  const { spawnSync } = require('child_process');
17
17
  const { acquireHookSlot } = require('./hook-lock.cjs');
18
18
  const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs');
19
+ const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs');
19
20
 
20
21
  /**
21
22
  * Read JSON input from stdin synchronously.
@@ -340,7 +341,7 @@ function handlePostToolUse(input) {
340
341
  // If HEAD matches last indexed commit, no reindex needed
341
342
  if (currentHead && currentHead === lastCommit) return;
342
343
 
343
- const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`;
344
+ const analyzeCmd = formatAnalyzeCommand({ embeddings: hadEmbeddings });
344
345
  sendHookResponse(
345
346
  'PostToolUse',
346
347
  `GitNexus index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` +
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Single source of truth for how docs, hooks, and warnings invoke gitnexus.
3
+ *
4
+ * Automatically selects a working invocation path:
5
+ * 1. Global `gitnexus` on PATH (best — no install step)
6
+ * 2. npm 11+ with pnpm on PATH → `pnpm --allow-build=… dlx` (avoids the npx
7
+ * arborist crash *and* pnpm 10+ ignored-build-script failures, #1939)
8
+ * 3. npm < 11 with npm on PATH → `npx` (works; simpler than pnpm dlx)
9
+ * 4. pnpm-only → `pnpm --allow-build=… dlx`
10
+ * 5. Last resort → `npx` (warned on npm 11+ from analyze.ts)
11
+ *
12
+ * The `--allow-build` flags MUST precede the `dlx` token. pnpm < 10.14 keeps
13
+ * `dlx` in its argv escape list, so flags placed *after* `dlx` are parsed as
14
+ * package specs (ERR_PNPM_SPEC_NOT_SUPPORTED). The pre-`dlx` position parses
15
+ * into dlx's allow-build option and has been honored since pnpm 10.2.0 (#1939).
16
+ *
17
+ * This stays self-contained CJS because the Claude/Antigravity hooks run as
18
+ * standalone files copied into the user's hook dir, where no package import is
19
+ * available. The CLI reuses this module from src/cli/resolve-invocation.ts via
20
+ * createRequire rather than re-implementing it. Two committed copies must stay
21
+ * byte-identical (enforced by resolve-invocation.test.ts) — edit both together:
22
+ * gitnexus/hooks/claude/ (the canonical copy the CLI and `gitnexus setup` read)
23
+ * and gitnexus-claude-plugin/hooks/. A THIRD copy is written at runtime to
24
+ * `<repo>/.gitnexus/run.cjs` by `gitnexus analyze` (ai-context.ts) so docs can
25
+ * reference it directly via the `require.main === module` exec tail below; that
26
+ * copy is gitignored and refreshed on every analyze, so it cannot drift for long.
27
+ */
28
+
29
+ const { execFileSync } = require('child_process');
30
+
31
+ const NPX_REF = 'gitnexus@latest';
32
+
33
+ // Native packages whose postinstall must run under pnpm 10+ (blocked by default).
34
+ const PNPM_ALLOW_BUILD_BASE = ['@ladybugdb/core', 'gitnexus', 'tree-sitter'];
35
+ const PNPM_ALLOW_BUILD_EMBEDDINGS = ['onnxruntime-node'];
36
+
37
+ // Probe timeout, kept under Claude Code's 10s hook budget. In a linked worktree
38
+ // the stale-index hook first runs `git rev-parse --git-common-dir` (~2s) and
39
+ // `git rev-parse HEAD` (~3s); the pnpm path then adds up to four 1s probes
40
+ // (which gitnexus, npm --version, which pnpm, pnpm --version), so the worst case
41
+ // is ~9s — within budget but tight. A healthy `which`/`where`/`--version`
42
+ // returns in well under a second, so the realistic cost is far lower.
43
+ const PROBE_TIMEOUT_MS = 1000;
44
+
45
+ /**
46
+ * Pick the best match from `where`/`which` output. A global `gitnexus` may be a
47
+ * `.cmd`/`.bat` (npm), a `.exe`, or an extensionless shim (Volta, scoop), so on
48
+ * Windows we prefer a recognized executable extension but accept any hit — the
49
+ * emitted hint is `gitnexus analyze` regardless of which shim resolves it. Pure
50
+ * and exported so the shim-matching can be unit-tested without spawning.
51
+ */
52
+ function pickPathMatch(output, { isWin, gitnexusWrapper } = {}) {
53
+ const lines = output
54
+ .split('\n')
55
+ .map((l) => l.trim())
56
+ .filter(Boolean);
57
+ if (isWin && gitnexusWrapper) {
58
+ return lines.find((l) => /\.(cmd|bat|exe)$/i.test(l)) || lines[0] || null;
59
+ }
60
+ return lines[0] || null;
61
+ }
62
+
63
+ /** Absolute path to `command` on PATH, or null. `gitnexusWrapper` enables the Windows shim match. */
64
+ function resolveOnPath(command, gitnexusWrapper = false) {
65
+ const isWin = process.platform === 'win32';
66
+ try {
67
+ const output = execFileSync(isWin ? 'where' : 'which', [command], {
68
+ encoding: 'utf-8',
69
+ timeout: PROBE_TIMEOUT_MS,
70
+ stdio: ['ignore', 'pipe', 'ignore'],
71
+ windowsHide: true,
72
+ });
73
+ return pickPathMatch(output, { isWin, gitnexusWrapper });
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ // One spawn of `<command> --version` → { major, minor } (each null when
80
+ // unreadable). Version injection happens at the resolver seam (getNpmMajorVersion
81
+ // / formatPnpmAllowBuildArgs), so this stays a pure real-process probe.
82
+ function probeVersion(command) {
83
+ try {
84
+ const output = execFileSync(command, ['--version'], {
85
+ encoding: 'utf-8',
86
+ timeout: PROBE_TIMEOUT_MS,
87
+ stdio: ['ignore', 'pipe', 'ignore'],
88
+ windowsHide: true,
89
+ // On Windows, npm/pnpm resolve to `.cmd` shims; execFileSync does no
90
+ // PATHEXT resolution and Node refuses to spawn `.cmd`/`.bat` without a
91
+ // shell (CVE-2024-27980), so a bare `<command> --version` ENOENTs and the
92
+ // probe would wrongly report a present tool as absent. A shell lets the OS
93
+ // resolve the shim. POSIX needs no shell (direct PATH lookup works).
94
+ shell: process.platform === 'win32',
95
+ });
96
+ // Find the first line that starts with a version token (`MAJOR.MINOR`,
97
+ // optional `v` prefix) rather than splitting the whole output — pnpm/npm
98
+ // under Corepack or with an update notice can print a banner line on stdout
99
+ // before the version (stderr is already dropped via the stdio config).
100
+ const versionLine = output
101
+ .split('\n')
102
+ .map((l) => l.trim())
103
+ .find((l) => /^v?\d+\.\d+/.test(l));
104
+ const match = versionLine ? versionLine.match(/^v?(\d+)\.(\d+)/) : null;
105
+ return {
106
+ major: match ? Number(match[1]) : null,
107
+ minor: match ? Number(match[2]) : null,
108
+ };
109
+ } catch {
110
+ return { major: null, minor: null };
111
+ }
112
+ }
113
+
114
+ // `deps` is the single injection seam: an explicitly provided key — including a
115
+ // `null` value, detected via `in` — is honored as-is so tests can simulate an
116
+ // absent tool without spawning; an absent key falls through to the real probe.
117
+ function getNpmMajorVersion(deps = {}) {
118
+ return 'npmMajor' in deps ? deps.npmMajor : probeVersion('npm').major;
119
+ }
120
+
121
+ /**
122
+ * `--allow-build` flags for the pre-`dlx` position. Emitted for pnpm >= 10.2
123
+ * (where the flag exists, and pnpm 10+ blocks build scripts by default). Omitted
124
+ * below 10.2: pnpm < 10 runs build scripts anyway, and pnpm 10.0/10.1 lack the
125
+ * flag (it would be rejected as an unknown option). `alwaysAllowBuild` forces the
126
+ * flags for committed documentation, which cannot probe the reader's pnpm.
127
+ */
128
+ function formatPnpmAllowBuildArgs(options = {}, deps = {}) {
129
+ if (!options.alwaysAllowBuild) {
130
+ const { major, minor } =
131
+ 'pnpmMajor' in deps
132
+ ? { major: deps.pnpmMajor, minor: 'pnpmMinor' in deps ? deps.pnpmMinor : null }
133
+ : probeVersion('pnpm');
134
+ const lacksAllowBuild =
135
+ major !== null && (major < 10 || (major === 10 && minor !== null && minor < 2));
136
+ if (lacksAllowBuild) return [];
137
+ }
138
+ const pkgs = [...PNPM_ALLOW_BUILD_BASE];
139
+ if (options.embeddings) pkgs.push(...PNPM_ALLOW_BUILD_EMBEDDINGS);
140
+ return pkgs.map((p) => `--allow-build=${p}`);
141
+ }
142
+
143
+ /** Fixed install-free command for committed AGENTS.md / SKILL.md (pnpm >= 10.2). */
144
+ function formatDocumentationDlxCommand(gitnexusArgs, options = {}) {
145
+ const flags = formatPnpmAllowBuildArgs({ ...options, alwaysAllowBuild: true }).join(' ');
146
+ const prefix = flags ? `${flags} ` : '';
147
+ return `pnpm ${prefix}dlx ${NPX_REF} ${gitnexusArgs}`;
148
+ }
149
+
150
+ /**
151
+ * Resolve `gitnexus` | `pnpm` | `npx`. `GITNEXUS_INVOCATION` forces a mode
152
+ * (test/escape hatch). `probe` is injectable so the preference order can be
153
+ * unit-tested without spawning; it defaults to the real PATH probe. `deps` can
154
+ * inject `{ npmMajor, pnpmMajor }` for tests.
155
+ */
156
+ function resolveInvocationMode(probe = resolveOnPath, deps = {}) {
157
+ const forced = process.env.GITNEXUS_INVOCATION?.trim().toLowerCase();
158
+ if (forced === 'gitnexus' || forced === 'pnpm' || forced === 'npx') {
159
+ return forced;
160
+ }
161
+ if (probe('gitnexus', true)) return 'gitnexus';
162
+
163
+ const npmMajor = getNpmMajorVersion(deps);
164
+ // pnpm presence: prefer an explicit `pnpmPresent` flag (set by
165
+ // formatAnalyzeCommand, which falls back to a PATH probe when the version is
166
+ // unreadable) so a present-but-unparseable pnpm — slow probe, Corepack
167
+ // banner — still selects pnpm instead of the npx crash path. Otherwise an
168
+ // injected version (a successful `pnpm --version` proves presence)
169
+ // short-circuits the `which pnpm` probe; failing both, fall back to PATH.
170
+ const hasPnpm =
171
+ 'pnpmPresent' in deps
172
+ ? deps.pnpmPresent
173
+ : 'pnpmMajor' in deps
174
+ ? deps.pnpmMajor !== null
175
+ : Boolean(probe('pnpm'));
176
+
177
+ // npm 11+ npx install crash (#1939) — prefer pnpm dlx when available.
178
+ if (hasPnpm && npmMajor !== null && npmMajor >= 11) return 'pnpm';
179
+ // npm 10 and earlier: npx works; prefer it over pnpm dlx when npm is present.
180
+ if (npmMajor !== null && npmMajor < 11) return 'npx';
181
+ // npm absent or unreadable — use pnpm if present (with allow-build flags).
182
+ if (hasPnpm) return 'pnpm';
183
+
184
+ return 'npx';
185
+ }
186
+
187
+ function formatPnpmDlxCommand(gitnexusArgs, options = {}, deps = {}) {
188
+ const flags = formatPnpmAllowBuildArgs(options, deps).join(' ');
189
+ const prefix = flags ? `${flags} ` : '';
190
+ return `pnpm ${prefix}dlx ${NPX_REF} ${gitnexusArgs}`;
191
+ }
192
+
193
+ function formatAnalyzeCommand(options = {}, deps = {}) {
194
+ const suffix = options.embeddings ? ' --embeddings' : '';
195
+ // Keep the stale-index hook budget tight by querying each tool at most once.
196
+ // A memoized PATH probe is shared with resolveInvocationMode (so `gitnexus`
197
+ // isn't probed twice), and pnpm's version is captured by a single
198
+ // `pnpm --version` that proves both presence (for mode resolution) and
199
+ // version (for the allow-build gate) — replacing the former `which pnpm` +
200
+ // `pnpm --version` double spawn. Injected deps (tests) and forced/global
201
+ // modes skip the pnpm probe.
202
+ const cache = new Map();
203
+ const probe = (command, gitnexusWrapper) => {
204
+ const key = `${command}:${gitnexusWrapper ? 1 : 0}`;
205
+ if (!cache.has(key)) cache.set(key, resolveOnPath(command, gitnexusWrapper));
206
+ return cache.get(key);
207
+ };
208
+ let resolved = deps;
209
+ if (!('pnpmMajor' in deps)) {
210
+ const forced = process.env.GITNEXUS_INVOCATION?.trim().toLowerCase();
211
+ // pnpm is only consulted when no non-pnpm mode is already certain: forced
212
+ // gitnexus/npx never use pnpm, and a present global gitnexus wins outright.
213
+ const mightUsePnpm = forced === 'pnpm' || (forced !== 'gitnexus' && forced !== 'npx');
214
+ if (mightUsePnpm && (forced === 'pnpm' || !probe('gitnexus', true))) {
215
+ const { major, minor } = probeVersion('pnpm');
216
+ // Carry presence separately from version: when the version probe fails
217
+ // (timeout, Corepack banner) but pnpm is on PATH, still treat it as
218
+ // present so mode resolution picks pnpm over the npx crash path. The
219
+ // PATH probe is memoized and only runs when the version is unreadable.
220
+ const pnpmPresent = major !== null || Boolean(probe('pnpm'));
221
+ resolved = { ...deps, pnpmMajor: major, pnpmMinor: minor, pnpmPresent };
222
+ }
223
+ }
224
+ const mode = resolveInvocationMode(probe, resolved);
225
+ if (mode === 'gitnexus') return `gitnexus analyze${suffix}`;
226
+ if (mode === 'pnpm') return `${formatPnpmDlxCommand(`analyze${suffix}`, options, resolved)}`;
227
+ return `npx ${NPX_REF} analyze${suffix}`;
228
+ }
229
+
230
+ /**
231
+ * Resolve `mode` into a concrete { program, args } pair for a set of gitnexus
232
+ * subcommand arguments. Shared by the direct-exec entrypoint below; pure (no
233
+ * spawn) so it is unit-testable. `--embeddings` widens the pnpm allow-build set.
234
+ */
235
+ function buildRunnerArgv(mode, gitnexusArgs, deps = {}) {
236
+ // Match both the space form (`--embeddings`) and the equals form
237
+ // (`--embeddings=5000`) Commander accepts, so the pnpm allow-build set still
238
+ // widens to onnxruntime-node when a user hand-types the equals form.
239
+ const embeddings = gitnexusArgs.some(
240
+ (a) => a === '--embeddings' || a.startsWith('--embeddings='),
241
+ );
242
+ if (mode === 'gitnexus') return { program: 'gitnexus', args: [...gitnexusArgs] };
243
+ if (mode === 'pnpm') {
244
+ return {
245
+ program: 'pnpm',
246
+ args: [...formatPnpmAllowBuildArgs({ embeddings }, deps), 'dlx', NPX_REF, ...gitnexusArgs],
247
+ };
248
+ }
249
+ return { program: 'npx', args: [NPX_REF, ...gitnexusArgs] };
250
+ }
251
+
252
+ module.exports = {
253
+ formatAnalyzeCommand,
254
+ formatDocumentationDlxCommand,
255
+ formatPnpmAllowBuildArgs,
256
+ formatPnpmDlxCommand,
257
+ resolveInvocationMode,
258
+ buildRunnerArgv,
259
+ pickPathMatch,
260
+ getNpmMajorVersion,
261
+ NPX_REF,
262
+ PNPM_ALLOW_BUILD_BASE,
263
+ };
264
+
265
+ // Direct-exec entrypoint (#1945): `node run.cjs <gitnexus args…>` resolves the
266
+ // best available runner (global `gitnexus` → `pnpm dlx` → `npx`) at call time and
267
+ // runs it, inheriting stdio and propagating the child's exit code. This lets the
268
+ // committed skills and generated AGENTS.md/CLAUDE.md reference ONE stable,
269
+ // CLI-neutral command without baking in a package-manager assumption. `gitnexus
270
+ // analyze` drops a copy of this file at `.gitnexus/run.cjs`. Skipped on require()
271
+ // (the CLI and tests reuse the exports above), so it runs only when invoked as a
272
+ // script.
273
+ if (require.main === module) {
274
+ const gitnexusArgs = process.argv.slice(2);
275
+ const { program, args } = buildRunnerArgv(resolveInvocationMode(), gitnexusArgs);
276
+ try {
277
+ execFileSync(program, args, {
278
+ stdio: 'inherit',
279
+ windowsHide: true,
280
+ // On Windows, `npx`/`pnpm`/`gitnexus` resolve to `.cmd`/`.ps1`/`.exe`
281
+ // shims (npm, Volta, Corepack, scoop). execFileSync does not do PATHEXT
282
+ // resolution and Node refuses to spawn `.cmd`/`.bat` without a shell
283
+ // (CVE-2024-27980), so a bare program name ENOENTs. A shell lets the OS
284
+ // resolve the shim; POSIX needs no shell (direct PATH lookup works).
285
+ shell: process.platform === 'win32',
286
+ });
287
+ } catch (err) {
288
+ // Make spawn failures (resolved program absent from PATH) self-explanatory
289
+ // instead of a silent exit 1, then propagate the runner's own exit code.
290
+ if (typeof err.status !== 'number') {
291
+ process.stderr.write(`gitnexus runner: could not launch \`${program}\` — ${err.message}\n`);
292
+ }
293
+ process.exit(typeof err.status === 'number' ? err.status : 1);
294
+ }
295
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.6-rc.113",
3
+ "version": "1.6.6-rc.115",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",
@@ -30,6 +30,7 @@ const PLATFORM_LOGIC = [
30
30
  'test/unit/setup-jsonc.test.ts',
31
31
  'test/unit/setup-codex.test.ts',
32
32
  'test/unit/setup-antigravity.test.ts',
33
+ 'test/unit/resolve-invocation.test.ts',
33
34
  'test/unit/platform-capabilities.test.ts',
34
35
  'test/unit/worker-pool-windows-quarantine.test.ts',
35
36
  'test/unit/lbug-pool-win-fts-probe.test.ts',
@@ -84,6 +85,7 @@ const SPAWN_CLI = [
84
85
  'test/integration/setup-antigravity.test.ts',
85
86
  'test/integration/antigravity-hook-e2e.test.ts',
86
87
  'test/unit/local-cli-subprocess.test.ts',
88
+ 'test/unit/runner-exec-tail.test.ts',
87
89
  ];
88
90
 
89
91
  // Worker threads tests — exercise real worker_threads which have
@@ -5,14 +5,16 @@ description: "Use when the user needs to run GitNexus CLI commands like analyze/
5
5
 
6
6
  # GitNexus CLI Commands
7
7
 
8
- All commands work via `npx` no global install required.
8
+ 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.
9
+
10
+ > **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).
9
11
 
10
12
  ## Commands
11
13
 
12
14
  ### analyze — Build or refresh the index
13
15
 
14
16
  ```bash
15
- npx gitnexus analyze
17
+ node .gitnexus/run.cjs analyze
16
18
  ```
17
19
 
18
20
  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
28
30
  ### status — Check index freshness
29
31
 
30
32
  ```bash
31
- npx gitnexus status
33
+ node .gitnexus/run.cjs status
32
34
  ```
33
35
 
34
36
  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
36
38
  ### clean — Delete the index
37
39
 
38
40
  ```bash
39
- npx gitnexus clean
41
+ node .gitnexus/run.cjs clean
40
42
  ```
41
43
 
42
44
  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
49
51
  ### wiki — Generate documentation from the graph
50
52
 
51
53
  ```bash
52
- npx gitnexus wiki
54
+ node .gitnexus/run.cjs wiki
53
55
  ```
54
56
 
55
57
  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
66
68
  ### list — Show all indexed repos
67
69
 
68
70
  ```bash
69
- npx gitnexus list
71
+ node .gitnexus/run.cjs list
70
72
  ```
71
73
 
72
74
  Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.
@@ -22,7 +22,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
22
22
  4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
23
23
  ```
24
24
 
25
- > If "Index is stale" → run `npx gitnexus analyze` in terminal.
25
+ > If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
26
26
 
27
27
  ## Checklist
28
28
 
@@ -23,7 +23,7 @@ description: "Use when the user asks how code works, wants to understand archite
23
23
  5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
24
24
  ```
25
25
 
26
- > If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal.
26
+ > If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
27
27
 
28
28
  ## Checklist
29
29
 
@@ -15,7 +15,7 @@ For any task involving code understanding, debugging, impact analysis, or refact
15
15
  2. **Match your task to a skill below** and **read that skill file**
16
16
  3. **Follow the skill's workflow and checklist**
17
17
 
18
- > If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
18
+ > If step 1 warns the index is stale, run `node .gitnexus/run.cjs analyze` in the terminal first.
19
19
 
20
20
  ## Skills
21
21
 
@@ -23,7 +23,7 @@ description: "Use when the user wants to know what will break if they change som
23
23
  4. Assess risk and report to user
24
24
  ```
25
25
 
26
- > If "Index is stale" → run `npx gitnexus analyze` in terminal.
26
+ > If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
27
27
 
28
28
  ## Checklist
29
29
 
@@ -26,7 +26,7 @@ description: "Use when the user wants to review a pull request, understand what
26
26
  6. Summarize findings with risk assessment
27
27
  ```
28
28
 
29
- > If "Index is stale" → run `npx gitnexus analyze` in terminal before reviewing.
29
+ > If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal before reviewing.
30
30
 
31
31
  ## Checklist
32
32
 
@@ -22,7 +22,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
22
22
  4. Plan update order: interfaces → implementations → callers → tests
23
23
  ```
24
24
 
25
- > If "Index is stale" → run `npx gitnexus analyze` in terminal.
25
+ > If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
26
26
 
27
27
  ## Checklists
28
28