pi-gitnexus-fork 0.7.0

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.
Files changed (117) hide show
  1. package/.github/workflows/ci.yml +20 -0
  2. package/.gitnexusignore +11 -0
  3. package/.sg-rules/async-function-must-await-or-return.yml +55 -0
  4. package/.sg-rules/catch-must-log-error.yml +78 -0
  5. package/.sg-rules/class-must-implement-or-extend.yml +61 -0
  6. package/.sg-rules/class-property-must-be-readonly.yml +61 -0
  7. package/.sg-rules/error-must-extend-base.yml +56 -0
  8. package/.sg-rules/generic-must-be-constrained.yml +60 -0
  9. package/.sg-rules/import-reexport-risk.yml +9 -0
  10. package/.sg-rules/missing-session-id-in-api.yml +16 -0
  11. package/.sg-rules/no-any-in-generic-args.yml +57 -0
  12. package/.sg-rules/no-await-in-promise-all.yml +28 -0
  13. package/.sg-rules/no-barrel-export.yml +17 -0
  14. package/.sg-rules/no-bq-write-in-module.yml +65 -0
  15. package/.sg-rules/no-console-except-error.yml +27 -0
  16. package/.sg-rules/no-console-in-server.yml +42 -0
  17. package/.sg-rules/no-empty-catch.yml +20 -0
  18. package/.sg-rules/no-empty-function.yml +24 -0
  19. package/.sg-rules/no-eval.yml +28 -0
  20. package/.sg-rules/no-explicit-any.yml +34 -0
  21. package/.sg-rules/no-hardcoded-placeholder-string.yml +23 -0
  22. package/.sg-rules/no-hardcoded-secrets.yml +32 -0
  23. package/.sg-rules/no-innerHTML.yml +22 -0
  24. package/.sg-rules/no-json-parse-without-trycatch.yml +33 -0
  25. package/.sg-rules/no-magic-numbers.yml +25 -0
  26. package/.sg-rules/no-nested-ternary.yml +21 -0
  27. package/.sg-rules/no-non-null-assertion.yml +25 -0
  28. package/.sg-rules/no-stub-implementation.yml +44 -0
  29. package/.sg-rules/no-throw-literal.yml +50 -0
  30. package/.sg-rules/no-todo-comment.yml +24 -0
  31. package/.sg-rules/no-ts-ignore-comment.yml +48 -0
  32. package/.sg-rules/no-type-assertion-in-jsx.yml +23 -0
  33. package/.sg-rules/no-unguarded-trim.yml +24 -0
  34. package/.sg-rules/no-unknown-without-narrowing.yml +76 -0
  35. package/.sg-rules/no-unsafe-bracket-access.yml +58 -0
  36. package/.sg-rules/no-unsafe-type-assertion.yml +45 -0
  37. package/.sg-rules/switch-must-be-exhaustive.yml +62 -0
  38. package/.sg-rules/zod-async-refine-without-abort.yml +62 -0
  39. package/.sg-rules/zod-enum-unsafe-access.yml +59 -0
  40. package/.sg-rules/zod-nested-object-deep-path.yml +70 -0
  41. package/.sg-rules/zod-optional-without-default-in-route.yml +50 -0
  42. package/.sg-rules/zod-parse-not-safe.yml +42 -0
  43. package/.sg-rules/zod-preprocess-without-fallback.yml +58 -0
  44. package/.sg-rules/zod-refine-no-return-undefined.yml +54 -0
  45. package/.sg-rules/zod-transform-without-output-type.yml +52 -0
  46. package/.sg-sha +1 -0
  47. package/.sgignore +4 -0
  48. package/AGENTS.md +1 -0
  49. package/CHANGELOG.md +99 -0
  50. package/LICENSE +21 -0
  51. package/README.md +113 -0
  52. package/biome.json +25 -0
  53. package/coverage/base.css +224 -0
  54. package/coverage/block-navigation.js +87 -0
  55. package/coverage/clover.xml +890 -0
  56. package/coverage/coverage-final.json +12 -0
  57. package/coverage/favicon.png +0 -0
  58. package/coverage/index.html +131 -0
  59. package/coverage/prettify.css +1 -0
  60. package/coverage/prettify.js +2 -0
  61. package/coverage/sort-arrow-sprite.png +0 -0
  62. package/coverage/sorter.js +210 -0
  63. package/coverage/src/augment-remote.ts.html +274 -0
  64. package/coverage/src/gitnexus.ts.html +1363 -0
  65. package/coverage/src/index.html +236 -0
  66. package/coverage/src/index.ts.html +1561 -0
  67. package/coverage/src/mcp-client-factory.ts.html +367 -0
  68. package/coverage/src/mcp-client-stdio.ts.html +736 -0
  69. package/coverage/src/mcp-client.ts.html +568 -0
  70. package/coverage/src/remote-mcp-client.ts.html +709 -0
  71. package/coverage/src/repo-resolver.ts.html +526 -0
  72. package/coverage/src/tools.ts.html +970 -0
  73. package/coverage/src/ui/index.html +131 -0
  74. package/coverage/src/ui/main-menu.ts.html +502 -0
  75. package/coverage/src/ui/settings-menu.ts.html +460 -0
  76. package/dist/augment-remote.d.ts +11 -0
  77. package/dist/augment-remote.js +55 -0
  78. package/dist/gitnexus.d.ts +103 -0
  79. package/dist/gitnexus.js +410 -0
  80. package/dist/index.d.ts +2 -0
  81. package/dist/index.js +479 -0
  82. package/dist/mcp-client-factory.d.ts +19 -0
  83. package/dist/mcp-client-factory.js +78 -0
  84. package/dist/mcp-client-stdio.d.ts +35 -0
  85. package/dist/mcp-client-stdio.js +186 -0
  86. package/dist/mcp-client.d.ts +45 -0
  87. package/dist/mcp-client.js +145 -0
  88. package/dist/remote-mcp-client.d.ts +43 -0
  89. package/dist/remote-mcp-client.js +181 -0
  90. package/dist/repo-resolver.d.ts +47 -0
  91. package/dist/repo-resolver.js +123 -0
  92. package/dist/tools.d.ts +6 -0
  93. package/dist/tools.js +230 -0
  94. package/dist/ui/main-menu.d.ts +33 -0
  95. package/dist/ui/main-menu.js +102 -0
  96. package/dist/ui/settings-menu.d.ts +16 -0
  97. package/dist/ui/settings-menu.js +95 -0
  98. package/docs/design/remote-mcp-backend.md +153 -0
  99. package/media/screenshot.png +0 -0
  100. package/package.json +61 -0
  101. package/sgconfig.yml +4 -0
  102. package/skills/gitnexus-debugging/SKILL.md +84 -0
  103. package/skills/gitnexus-exploring/SKILL.md +73 -0
  104. package/skills/gitnexus-impact-analysis/SKILL.md +93 -0
  105. package/skills/gitnexus-pr-review/SKILL.md +109 -0
  106. package/skills/gitnexus-refactoring/SKILL.md +85 -0
  107. package/src/augment-remote.ts +63 -0
  108. package/src/gitnexus.ts +426 -0
  109. package/src/index.ts +492 -0
  110. package/src/mcp-client-factory.ts +94 -0
  111. package/src/mcp-client-stdio.ts +217 -0
  112. package/src/mcp-client.ts +208 -0
  113. package/src/remote-mcp-client.ts +250 -0
  114. package/src/repo-resolver.ts +147 -0
  115. package/src/tools.ts +295 -0
  116. package/src/ui/main-menu.ts +139 -0
  117. package/src/ui/settings-menu.ts +125 -0
package/dist/index.js ADDED
@@ -0,0 +1,479 @@
1
+ import { delimiter } from 'node:path';
2
+ import spawn from 'cross-spawn';
3
+ import { clearIndexCache, DEFAULT_SERVER_URL, extractFilePatternsFromContent, extractFilesFromReadMany, extractPattern, findGitNexusIndex, findGitNexusRoot, gitnexusCmd, loadSavedConfig, resolveGitNexusCmd, runAugment, setAugmentTimeout, setGitnexusCmd, spawnEnv, updateSpawnEnv } from './gitnexus';
4
+ import { createMcpClient, mcpClient } from './mcp-client';
5
+ import { registerTools } from './tools';
6
+ import { openMainMenu } from './ui/main-menu';
7
+ const SEARCH_TOOLS = new Set(['grep', 'find', 'bash', 'read', 'read_many']);
8
+ /**
9
+ * Run augment via remote MCP query tool instead of local subprocess.
10
+ * Falls back to empty string on any error (graceful degradation).
11
+ */
12
+ async function remoteAugment(pattern, cwd) {
13
+ if (!augmentMcpClient)
14
+ return '';
15
+ try {
16
+ const repo = findGitNexusRoot(cwd) ?? cwd;
17
+ return await augmentMcpClient.callTool('query', { query: pattern, repo, limit: 3 }, cwd);
18
+ }
19
+ catch {
20
+ return '';
21
+ }
22
+ }
23
+ /** Run augment: local subprocess or remote MCP depending on mode. */
24
+ function augment(pattern, cwd) {
25
+ return isRemoteMode ? remoteAugment(pattern, cwd) : runAugment(pattern, cwd);
26
+ }
27
+ /**
28
+ * Merge two PATH values, preferring the agent's PATH over the login shell's PATH
29
+ * while preserving order. Any shell-only directories (e.g. nvm/fnm/volta paths
30
+ * that the agent didn't inherit) are appended at the end so they are still found.
31
+ *
32
+ * This prevents the agent's existing PATH entries (such as ~/.local/share/nvm/…)
33
+ * from being silently dropped when the login shell reports a different PATH.
34
+ */
35
+ function mergePaths(agent, shell) {
36
+ const seen = new Set();
37
+ const out = [];
38
+ for (const dir of [...agent.split(delimiter), ...shell.split(delimiter)]) {
39
+ if (!dir)
40
+ continue;
41
+ const key = process.platform === 'win32' ? dir.toLowerCase() : dir;
42
+ if (seen.has(key))
43
+ continue;
44
+ seen.add(key);
45
+ out.push(dir);
46
+ }
47
+ return out.join(delimiter);
48
+ }
49
+ /**
50
+ * Resolve PATH from a login shell so nvm/fnm/volta binaries are visible.
51
+ * Merges with the agent's current PATH so directories already present
52
+ * (e.g. ~/.local/share/nvm/…) are never lost.
53
+ *
54
+ * On Windows the agent's PATH is already correct so we skip the probe entirely.
55
+ * On macOS/Linux we use the user's login shell ($SHELL) to read login startup
56
+ * files (.zprofile, .bash_profile, .profile), which is where most package
57
+ * managers place their PATH setup.
58
+ *
59
+ * The probe is bounded by a timeout to prevent a slow or broken startup file
60
+ * from stalling session initialization.
61
+ */
62
+ async function resolveShellPath() {
63
+ if (process.platform === 'win32')
64
+ return;
65
+ const loginShell = process.env.SHELL ?? '/bin/sh';
66
+ const shellPath = await new Promise((resolve_) => {
67
+ let out = '';
68
+ let done = false;
69
+ const finish = (value) => {
70
+ if (done)
71
+ return;
72
+ done = true;
73
+ clearTimeout(timer);
74
+ resolve_(value);
75
+ };
76
+ const proc = spawn(loginShell, ['-lc', 'printf %s "$PATH"'], {
77
+ stdio: ['ignore', 'pipe', 'ignore'],
78
+ });
79
+ const timer = setTimeout(() => {
80
+ proc.kill();
81
+ finish(process.env.PATH ?? '');
82
+ }, 3_000);
83
+ proc.stdout.on('data', (d) => { out += d.toString(); });
84
+ proc.on('close', () => finish(out.trim() || (process.env.PATH ?? '')));
85
+ proc.on('error', () => finish(process.env.PATH ?? ''));
86
+ });
87
+ const merged = mergePaths(process.env.PATH ?? '', shellPath);
88
+ updateSpawnEnv({ ...process.env, PATH: merged });
89
+ }
90
+ function trySpawn(bin, args) {
91
+ return new Promise((resolve_) => {
92
+ const proc = spawn(bin, args, { stdio: 'ignore', env: spawnEnv });
93
+ proc.on('close', (code) => resolve_(code === 0));
94
+ proc.on('error', () => resolve_(false));
95
+ });
96
+ }
97
+ /** Probe for gitnexus using the configured command. */
98
+ async function probeGitNexusBinary() {
99
+ const [bin, ...args] = gitnexusCmd;
100
+ return trySpawn(bin, [...args, '--version']);
101
+ }
102
+ /** Cached from session_start/session_switch — avoids re-probing on every /gitnexus status. */
103
+ let binaryAvailable = false;
104
+ /** Working directory of the current session — ctx.cwd in tool_result events may differ. */
105
+ let sessionCwd = '';
106
+ /** Persisted config — loaded on session_start, mutated by the settings menu. */
107
+ let cfg = {};
108
+ /** The active MCP client for augment hooks — may be remote or stdio. */
109
+ let augmentMcpClient = null;
110
+ /** Whether the augment hook should use remote MCP instead of local runAugment. */
111
+ let isRemoteMode = false;
112
+ /** Controls whether the tool_result hook auto-appends graph context. Tools are unaffected. */
113
+ let augmentEnabled = true;
114
+ /** Number of successful augmentations this session. Shown in /gitnexus status. */
115
+ let augmentHits = 0;
116
+ /** Number of times the tool_result hook intercepted a search tool result this session. */
117
+ let hookFires = 0;
118
+ /**
119
+ * Patterns already augmented this session (with non-empty results).
120
+ * Prevents the same symbol/file from being looked up repeatedly.
121
+ * Keys are lowercased for case-insensitive dedup.
122
+ */
123
+ const augmentedCache = new Set();
124
+ /**
125
+ * Patterns that returned empty results on first attempt.
126
+ * Prevents unbounded retries. Cleared on session reset (index rebuild).
127
+ */
128
+ const emptyCache = new Set();
129
+ export default function (pi) {
130
+ registerTools(pi);
131
+ pi.registerFlag('gitnexus-cmd', {
132
+ type: 'string',
133
+ default: '',
134
+ description: 'Command used to invoke gitnexus, e.g. "npx gitnexus@latest". Empty uses saved config or plain "gitnexus".',
135
+ });
136
+ // Append a one-liner so the agent understands graph context in search results.
137
+ pi.on('before_agent_start', async (event, ctx) => {
138
+ if (!findGitNexusIndex(ctx.cwd))
139
+ return;
140
+ if (event.systemPrompt == null)
141
+ return;
142
+ return {
143
+ systemPrompt: event.systemPrompt +
144
+ '\n\n[GitNexus active] Graph context will appear after search results. ' +
145
+ 'Use gitnexus_query, gitnexus_context, gitnexus_impact, gitnexus_detect_changes, ' +
146
+ 'gitnexus_list_repos, gitnexus_rename, and gitnexus_cypher for deeper analysis. ' +
147
+ 'If the index is stale after code changes, run /gitnexus analyze to rebuild it.',
148
+ };
149
+ });
150
+ // Core hook: mirrors the Claude Code PreToolUse integration.
151
+ // Intercepts grep/find/bash/read results, appends knowledge graph context.
152
+ pi.on('tool_result', async (event, ctx) => {
153
+ if (!augmentEnabled)
154
+ return;
155
+ if (!SEARCH_TOOLS.has(event.toolName))
156
+ return;
157
+ // Guard: event.content may be undefined for error results.
158
+ if (!event.content || !Array.isArray(event.content))
159
+ return;
160
+ hookFires++;
161
+ const cwd = sessionCwd || ctx.cwd;
162
+ if (!findGitNexusIndex(cwd))
163
+ return;
164
+ // read_many: per-file labeled context so the agent knows which context belongs to which file.
165
+ if (event.toolName === 'read_many') {
166
+ const files = extractFilesFromReadMany(event.input, event.content);
167
+ const fresh = files.filter(f => {
168
+ const key = f.pattern.toLowerCase();
169
+ return !augmentedCache.has(key) && !emptyCache.has(key);
170
+ }).slice(0, 5);
171
+ if (fresh.length === 0)
172
+ return;
173
+ const results = await Promise.all(fresh.map(f => augment(f.pattern, cwd).then(out => ({ f, out }))));
174
+ // Cache based on results: successful → augmentedCache, empty → emptyCache
175
+ for (const r of results) {
176
+ const key = r.f.pattern.toLowerCase();
177
+ if (r.out) {
178
+ augmentedCache.add(key);
179
+ }
180
+ else {
181
+ emptyCache.add(key);
182
+ }
183
+ }
184
+ const sections = results.filter(r => r.out);
185
+ if (sections.length === 0)
186
+ return;
187
+ augmentHits++;
188
+ const body = sections.length === 1
189
+ ? sections[0].out
190
+ : sections.map(({ f, out }) => `### ${f.path.split('/').pop()}\n${out}`).join('\n\n');
191
+ const label = body.startsWith('[GitNexus]') ? '' : (sections.length === 1
192
+ ? `[GitNexus: ${sections[0].f.path.split('/').pop()}]\n`
193
+ : '[GitNexus]\n');
194
+ return {
195
+ content: [
196
+ ...event.content,
197
+ { type: 'text', text: `\n\n---\n${label}${body}\n---` },
198
+ ],
199
+ };
200
+ }
201
+ // Early-exit: skip enrichment when the tool returned no meaningful content.
202
+ const contentText = event.content.map((c) => c.text ?? '').join('');
203
+ if (contentText.length < 10)
204
+ return;
205
+ // Collect patterns: primary from input, secondary filenames from result content.
206
+ const primary = extractPattern(event.toolName, event.input);
207
+ const secondaryLimit = cfg.maxSecondaryPatterns ?? 2;
208
+ const secondary = (event.toolName === 'grep' || event.toolName === 'bash')
209
+ ? extractFilePatternsFromContent(event.content, secondaryLimit)
210
+ : [];
211
+ const candidates = [...new Set([primary, ...secondary].filter((p) => !!p))];
212
+ // Filter patterns already augmented or known-empty this session (case-insensitive).
213
+ const fresh = candidates.filter(p => {
214
+ const key = p.toLowerCase();
215
+ return !augmentedCache.has(key) && !emptyCache.has(key);
216
+ });
217
+ if (fresh.length === 0)
218
+ return;
219
+ // Run augments in parallel, merge results.
220
+ const maxAugments = cfg.maxAugmentsPerResult ?? 3;
221
+ const toRun = fresh.slice(0, maxAugments);
222
+ const results = await Promise.all(toRun.map(p => augment(p, cwd).then(out => ({ p, out }))));
223
+ // Cache based on results: successful → augmentedCache, empty → emptyCache
224
+ for (const r of results) {
225
+ const key = r.p.toLowerCase();
226
+ if (r.out) {
227
+ augmentedCache.add(key);
228
+ }
229
+ else {
230
+ emptyCache.add(key);
231
+ }
232
+ }
233
+ const combined = results.filter(r => r.out).map(r => r.out).join('\n\n');
234
+ if (!combined)
235
+ return;
236
+ augmentHits++;
237
+ const label = combined.startsWith('[GitNexus]') ? '' : `[GitNexus: ${toRun.join(', ')}]\n`;
238
+ return {
239
+ content: [
240
+ ...event.content,
241
+ { type: 'text', text: `\n\n---\n${label}${combined}\n---` },
242
+ ],
243
+ };
244
+ });
245
+ async function onSession(ctx) {
246
+ mcpClient.stop();
247
+ augmentMcpClient?.stop();
248
+ augmentMcpClient = null;
249
+ isRemoteMode = false;
250
+ clearIndexCache();
251
+ augmentHits = 0;
252
+ hookFires = 0;
253
+ augmentedCache.clear();
254
+ emptyCache.clear();
255
+ sessionCwd = ctx.cwd;
256
+ await resolveShellPath();
257
+ // Load persisted config
258
+ cfg = loadSavedConfig();
259
+ augmentEnabled = cfg.autoAugment !== false;
260
+ if (cfg.augmentTimeout)
261
+ setAugmentTimeout(cfg.augmentTimeout);
262
+ // Resolve command: default → saved config → CLI flag (highest precedence)
263
+ const flag = pi.getFlag('gitnexus-cmd');
264
+ setGitnexusCmd(resolveGitNexusCmd(flag, cfg.cmd));
265
+ binaryAvailable = await probeGitNexusBinary();
266
+ if (!findGitNexusIndex(ctx.cwd))
267
+ return;
268
+ // Determine transport mode for augment hook
269
+ const mode = cfg.mode ?? 'auto';
270
+ const serverUrl = cfg.serverUrl || DEFAULT_SERVER_URL;
271
+ if (mode === 'remote' || (mode === 'auto' && !binaryAvailable)) {
272
+ isRemoteMode = true;
273
+ augmentMcpClient = createMcpClient('remote', serverUrl);
274
+ ctx.ui.notify(`GitNexus: using remote MCP backend (${serverUrl}).`, 'info');
275
+ }
276
+ else if (binaryAvailable) {
277
+ ctx.ui.notify('GitNexus: knowledge graph active — searches will be enriched automatically.', 'info');
278
+ }
279
+ else {
280
+ ctx.ui.notify('GitNexus index found but gitnexus is not on PATH. Install: npm i -g gitnexus', 'warning');
281
+ }
282
+ }
283
+ pi.on('session_start', (_event, ctx) => {
284
+ void onSession(ctx).catch(err => {
285
+ ctx.ui.notify(`GitNexus session init failed: ${err.message}`, 'error');
286
+ });
287
+ });
288
+ const subcommands = ['status', 'analyze', 'on', 'off', 'settings', 'query', 'context', 'impact', 'help'];
289
+ pi.registerCommand('gitnexus', {
290
+ description: 'GitNexus knowledge graph. Type /gitnexus help for usage.',
291
+ getArgumentCompletions: (prefix) => {
292
+ const items = subcommands
293
+ .filter(s => s.startsWith(prefix))
294
+ .map(s => ({ value: s, label: s }));
295
+ return items.length > 0 ? items : null;
296
+ },
297
+ handler: async (args, ctx) => {
298
+ const parts = args.trim().split(/\s+/);
299
+ const sub = parts[0] ?? '';
300
+ const rest = parts.slice(1).join(' ');
301
+ // /gitnexus status
302
+ if (sub === 'status') {
303
+ if (!binaryAvailable) {
304
+ ctx.ui.notify('gitnexus is not installed. Install: npm i -g gitnexus', 'warning');
305
+ return;
306
+ }
307
+ if (!findGitNexusIndex(ctx.cwd)) {
308
+ ctx.ui.notify('No GitNexus index found. Run: /gitnexus analyze', 'info');
309
+ return;
310
+ }
311
+ const out = await new Promise((resolve_) => {
312
+ let stdout = '';
313
+ const [bin, ...baseArgs] = gitnexusCmd;
314
+ const proc = spawn(bin, [...baseArgs, 'status'], {
315
+ cwd: ctx.cwd,
316
+ stdio: ['ignore', 'pipe', 'ignore'],
317
+ env: spawnEnv,
318
+ });
319
+ proc.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
320
+ proc.on('close', () => resolve_(stdout.trim()));
321
+ proc.on('error', () => resolve_(''));
322
+ });
323
+ const augmentLine = augmentEnabled
324
+ ? `Auto-augment: on (${hookFires} intercepted, ${augmentHits} enriched this session)`
325
+ : 'Auto-augment: off';
326
+ ctx.ui.notify((out ? out + '\n' : '') + augmentLine, 'info');
327
+ return;
328
+ }
329
+ // /gitnexus help
330
+ if (sub === 'help') {
331
+ ctx.ui.notify('/gitnexus — GitNexus knowledge graph\n' +
332
+ '\n' +
333
+ 'Commands:\n' +
334
+ ' /gitnexus — interactive menu (status & settings)\n' +
335
+ ' /gitnexus status — show status\n' +
336
+ ' /gitnexus analyze — index the codebase\n' +
337
+ ' /gitnexus on|off — enable/disable auto-augment on searches\n' +
338
+ ' /gitnexus <pattern> — manual graph lookup\n' +
339
+ ' /gitnexus query <q> — search execution flows\n' +
340
+ ' /gitnexus context <n> — callers/callees of a symbol\n' +
341
+ ' /gitnexus impact <n> — blast radius of a change\n' +
342
+ '\n' +
343
+ 'Tools (always available to the agent):\n' +
344
+ ' gitnexus_list_repos, gitnexus_query, gitnexus_context,\n' +
345
+ ' gitnexus_impact, gitnexus_detect_changes,\n' +
346
+ ' gitnexus_rename, gitnexus_cypher', 'info');
347
+ return;
348
+ }
349
+ // /gitnexus on | off
350
+ if (sub === 'on' || sub === 'off') {
351
+ augmentEnabled = sub === 'on';
352
+ ctx.ui.notify(`GitNexus auto-augment ${augmentEnabled ? 'enabled' : 'disabled'}.`, 'info');
353
+ return;
354
+ }
355
+ // /gitnexus or /gitnexus config | settings — main menu
356
+ if (!sub || sub === 'config' || sub === 'settings') {
357
+ const state = { augmentEnabled };
358
+ await openMainMenu({
359
+ ui: ctx.ui,
360
+ cwd: ctx.cwd,
361
+ cfg,
362
+ state,
363
+ binaryAvailable,
364
+ gitnexusCmd,
365
+ spawnEnv,
366
+ getHookFires: () => hookFires,
367
+ getAugmentHits: () => augmentHits,
368
+ findGitNexusIndex,
369
+ clearIndexCache,
370
+ setGitnexusCmd,
371
+ setAugmentTimeout,
372
+ syncState: () => {
373
+ augmentEnabled = state.augmentEnabled;
374
+ if (cfg.cmd)
375
+ setGitnexusCmd(cfg.cmd.trim().split(/\s+/));
376
+ if (cfg.augmentTimeout)
377
+ setAugmentTimeout(cfg.augmentTimeout);
378
+ },
379
+ });
380
+ return;
381
+ }
382
+ // /gitnexus analyze
383
+ if (sub === 'analyze') {
384
+ if (!binaryAvailable) {
385
+ ctx.ui.notify('gitnexus is not installed. Install: npm i -g gitnexus', 'warning');
386
+ return;
387
+ }
388
+ augmentEnabled = false;
389
+ ctx.ui.notify('GitNexus: analyzing codebase, this may take a while…', 'info');
390
+ const exitCode = await new Promise((resolve_) => {
391
+ const [bin, ...baseArgs] = gitnexusCmd;
392
+ const proc = spawn(bin, [...baseArgs, 'analyze'], {
393
+ cwd: ctx.cwd,
394
+ stdio: 'ignore',
395
+ env: spawnEnv,
396
+ });
397
+ proc.on('close', resolve_);
398
+ proc.on('error', () => resolve_(null));
399
+ });
400
+ if (exitCode === 0) {
401
+ clearIndexCache();
402
+ augmentEnabled = true;
403
+ ctx.ui.notify('GitNexus: analysis complete. Knowledge graph ready.', 'info');
404
+ }
405
+ else {
406
+ augmentEnabled = true;
407
+ ctx.ui.notify('GitNexus: analysis failed. Check the terminal for details.', 'error');
408
+ }
409
+ return;
410
+ }
411
+ const repo = findGitNexusRoot(ctx.cwd) ?? ctx.cwd;
412
+ // /gitnexus query <text>
413
+ if (sub === 'query') {
414
+ if (!rest) {
415
+ ctx.ui.notify('Usage: /gitnexus query <text>', 'info');
416
+ return;
417
+ }
418
+ try {
419
+ const out = await mcpClient.callTool('query', { query: rest, repo }, ctx.cwd);
420
+ if (out)
421
+ pi.sendUserMessage(out, { deliverAs: 'followUp' });
422
+ else
423
+ ctx.ui.notify('No results.', 'info');
424
+ }
425
+ catch (error) {
426
+ ctx.ui.notify(error instanceof Error ? error.message : 'GitNexus query failed.', 'error');
427
+ }
428
+ return;
429
+ }
430
+ // /gitnexus context <name>
431
+ if (sub === 'context') {
432
+ if (!rest) {
433
+ ctx.ui.notify('Usage: /gitnexus context <name>', 'info');
434
+ return;
435
+ }
436
+ try {
437
+ const out = await mcpClient.callTool('context', { name: rest, repo }, ctx.cwd);
438
+ if (out)
439
+ pi.sendUserMessage(out, { deliverAs: 'followUp' });
440
+ else
441
+ ctx.ui.notify('No results.', 'info');
442
+ }
443
+ catch (error) {
444
+ ctx.ui.notify(error instanceof Error ? error.message : 'GitNexus context lookup failed.', 'error');
445
+ }
446
+ return;
447
+ }
448
+ // /gitnexus impact <name>
449
+ if (sub === 'impact') {
450
+ if (!rest) {
451
+ ctx.ui.notify('Usage: /gitnexus impact <name>', 'info');
452
+ return;
453
+ }
454
+ try {
455
+ const out = await mcpClient.callTool('impact', { target: rest, direction: 'upstream', repo }, ctx.cwd);
456
+ if (out)
457
+ pi.sendUserMessage(out, { deliverAs: 'followUp' });
458
+ else
459
+ ctx.ui.notify('No results.', 'info');
460
+ }
461
+ catch (error) {
462
+ ctx.ui.notify(error instanceof Error ? error.message : 'GitNexus impact analysis failed.', 'error');
463
+ }
464
+ return;
465
+ }
466
+ // /gitnexus <pattern> — manual augment lookup
467
+ const pattern = sub + (rest ? ' ' + rest : '');
468
+ if (pattern.length < 3) {
469
+ ctx.ui.notify('Pattern too short (min 3 chars).', 'info');
470
+ return;
471
+ }
472
+ const out = await runAugment(pattern, ctx.cwd);
473
+ if (out)
474
+ pi.sendUserMessage('[GitNexus]\n' + out, { deliverAs: 'followUp' });
475
+ else
476
+ ctx.ui.notify('No graph context found for: ' + pattern, 'info');
477
+ },
478
+ });
479
+ }
@@ -0,0 +1,19 @@
1
+ import type { McpClient } from './mcp-client';
2
+ export interface McpClientFactoryConfig {
3
+ mode?: 'auto' | 'local' | 'remote';
4
+ serverUrl?: string;
5
+ }
6
+ export declare class McpClientFactory {
7
+ /**
8
+ * Create an MCP client based on the given configuration.
9
+ * For mode='auto', this is async (probes local binary first).
10
+ */
11
+ static createClient(config: McpClientFactoryConfig): Promise<McpClient>;
12
+ /**
13
+ * Load config from ~/.pi/pi-gitnexus.json with env var overrides.
14
+ */
15
+ static loadConfig(): {
16
+ mode: 'auto' | 'local' | 'remote';
17
+ serverUrl?: string;
18
+ };
19
+ }
@@ -0,0 +1,78 @@
1
+ import spawn from 'cross-spawn';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { homedir } from 'os';
4
+ import { gitnexusCmd, spawnEnv, validateMcpMode } from './gitnexus';
5
+ /** Default remote MCP server URL. */
6
+ const DEFAULT_SERVER_URL = 'http://100.114.135.99:4747/api/mcp';
7
+ const CONFIG_PATH = `${homedir()}/.pi/pi-gitnexus.json`;
8
+ export class McpClientFactory {
9
+ /**
10
+ * Create an MCP client based on the given configuration.
11
+ * For mode='auto', this is async (probes local binary first).
12
+ */
13
+ static async createClient(config) {
14
+ const mode = config.mode ?? 'auto';
15
+ const serverUrl = config.serverUrl || DEFAULT_SERVER_URL;
16
+ if (mode === 'local') {
17
+ const mod = await import('./mcp-client');
18
+ return construct(mod.StdioMcpClient);
19
+ }
20
+ if (mode === 'remote') {
21
+ const mod = await import('./remote-mcp-client');
22
+ return construct(mod.RemoteMcpClient, { serverUrl });
23
+ }
24
+ // mode === 'auto': probe local binary first
25
+ const localWorks = await probeLocalBinary();
26
+ if (localWorks) {
27
+ const mod = await import('./mcp-client');
28
+ return construct(mod.StdioMcpClient);
29
+ }
30
+ const mod = await import('./remote-mcp-client');
31
+ return construct(mod.RemoteMcpClient, { serverUrl });
32
+ }
33
+ /**
34
+ * Load config from ~/.pi/pi-gitnexus.json with env var overrides.
35
+ */
36
+ static loadConfig() {
37
+ let raw = {};
38
+ try {
39
+ if (existsSync(CONFIG_PATH)) {
40
+ raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
41
+ }
42
+ }
43
+ catch {
44
+ // ignore read errors
45
+ }
46
+ const envMode = process.env.GITNEXUS_MODE ? validateMcpMode(process.env.GITNEXUS_MODE) : undefined;
47
+ const envUrl = process.env.GITNEXUS_SERVER_URL || undefined;
48
+ return {
49
+ mode: envMode ?? validateMcpMode(raw.mode),
50
+ serverUrl: envUrl ?? (typeof raw.serverUrl === 'string' && raw.serverUrl.trim() ? raw.serverUrl.trim() : DEFAULT_SERVER_URL),
51
+ };
52
+ }
53
+ }
54
+ /**
55
+ * Construct an instance from a class or mock function.
56
+ * Works with both real ES classes and vi.fn() mocks.
57
+ */
58
+ function construct(Ctor, ...args) {
59
+ // For vi.fn() mocks, calling without new returns the mock value.
60
+ // For real classes, we need new. Try both.
61
+ try {
62
+ return new Ctor(...args);
63
+ }
64
+ catch {
65
+ return Ctor(...args);
66
+ }
67
+ }
68
+ function probeLocalBinary() {
69
+ return new Promise((resolve_) => {
70
+ const [bin, ...baseArgs] = gitnexusCmd;
71
+ const proc = spawn(bin, [...baseArgs, '--version'], {
72
+ stdio: 'ignore',
73
+ env: spawnEnv,
74
+ });
75
+ proc.on('close', (code) => resolve_(code === 0));
76
+ proc.on('error', () => resolve_(false));
77
+ });
78
+ }
@@ -0,0 +1,35 @@
1
+ import type { McpClient } from './mcp-client';
2
+ /**
3
+ * Thin stdio JSON-RPC 2.0 client for `gitnexus mcp`.
4
+ *
5
+ * Communication is exclusively over the spawned process's stdin/stdout pipe —
6
+ * no network socket, no port. Only our process can write to the pipe.
7
+ *
8
+ * The MCP process is started lazily on the first callTool() invocation and
9
+ * kept alive for the session lifetime. stop() terminates it; the next callTool()
10
+ * re-spawns with the new cwd.
11
+ */
12
+ export declare class StdioMcpClient implements McpClient {
13
+ private proc;
14
+ private buffer;
15
+ private pending;
16
+ private nextId;
17
+ private startPromise;
18
+ /**
19
+ * Probe the local gitnexus binary. Returns true if it responds to --version.
20
+ * Static method for use by AutoMcpClient without instantiating.
21
+ */
22
+ static probeLocalBinary(): Promise<boolean>;
23
+ /**
24
+ * Lazily spawn `gitnexus mcp` and complete the MCP initialize handshake.
25
+ * Idempotent — concurrent calls await the same promise; only one process spawns.
26
+ */
27
+ private ensureStarted;
28
+ /**
29
+ * Call a gitnexus MCP tool and return its formatted text response.
30
+ * Starts the MCP process lazily if not already running.
31
+ */
32
+ callTool(name: string, args: Record<string, unknown>, cwd: string): Promise<string>;
33
+ /** Terminate the MCP process. Called on session_start so the next session gets a fresh process. */
34
+ stop(): void;
35
+ }